Skip to content

Instantly share code, notes, and snippets.

@neon-sunset
Last active September 20, 2026 19:39
Show Gist options
  • Select an option

  • Save neon-sunset/ff4b2629c2c95b02f9b5cdddd4fb811b to your computer and use it in GitHub Desktop.

Select an option

Save neon-sunset/ff4b2629c2c95b02f9b5cdddd4fb811b to your computer and use it in GitHub Desktop.
combase-lifetime-disasm.md
/* Reconstruction of the COM object lifetime path in combase.dll 10.0.28000.2952 (x64),
from public symbols (combase.pdb, 27561 S_PUB32 records) and .pdata-bounded disassembly.
Field names below are offsets recovered from the code, not from any header. Where a
flag bit has no name in the symbols, it is written as its literal value and the note
says only what the code does with it. */
/* ---------- object layout, recovered ---------------------------------------------
CStdIdentity + 0x008 CStdMarshal base subobject (passed to CStdMarshal::GetFlags)
CStdIdentity + 0x0a0 CIDObject *pIDObj (passed to COIDTable::Remove)
CStdIdentity + 0x0c0 std::atomic<bool> (verifier one-shot latch)
CStdIdentity + 0x138 CInternalUnk subobject (the inner unknown)
CStdIdentity + 0x148 ULONG refs (THE reference count)
CStdIdentity + 0x14c ULONG flags (legacy, non-atomic)
CStdIdentity + 0x150 ULONG flags (atomic; see feature gate below)
CStdIdentity + 0x158 IUnknown *pUnkControl (controlling unknown)
CStdIdentity + 0x188 ULONG (compared against -1)
refs encoding (from InterlockedDecRefCnt / InterlockedRestoreRefCnt):
bits 0..30 the count
bit 31 destruction in progress
0x80000100 stored when the last Release lands on a locked object
flag bits, by observed use only:
0x0001 IsClient()
0x0004 tested and cleared by RevokeOID
0x0008 when set, RevokeOID does not call COIDTable::Remove
0x0100 part of IsLockedOrInDestructor's 0x300 mask
0x0200 part of that mask; cleared by UnlockAndRelease
0x2000 RemovedFromIDObj
------------------------------------------------------------------------------- */
/* Two flag words exist because a fix is staged behind a WIL feature gate. Every
accessor picks the word at run time. Same shape in InterlockedDecRefCnt. */
ULONG CStdIdentity::GetStdIdFlags() const /* 0x1840e4 */
{
if (Feature_FixNonAtomicAccessInIpidtbl::IsEnabled())
return this->flags_0x150; /* plain load of the atomic word */
return this->flags_0x14c;
}
void CStdIdentity::SetTheseStdIdFlags(ULONG m) /* 0x1841ac */
{
if (Feature_FixNonAtomicAccessInIpidtbl::IsEnabled())
_InterlockedOr(&this->flags_0x150, m); /* lock or */
else
this->flags_0x14c |= m; /* not interlocked */
}
void CStdIdentity::ClearTheseStdIdFlags(ULONG m) /* 0x1a6eb4 */
{
if (Feature_FixNonAtomicAccessInIpidtbl::IsEnabled())
_InterlockedAnd(&this->flags_0x150, ~m); /* lock and */
else
this->flags_0x14c &= ~m;
}
int CStdIdentity::IsClient() const { return GetStdIdFlags() & 0x0001; } /* 0x85060 */
int CStdIdentity::IsLockedOrInDestructor() { return GetStdIdFlags() & 0x0300; } /* 0x4c84c */
int CStdIdentity::IsRemovedFromIDObj() { return GetStdIdFlags() & 0x2000; } /* 0x4c8ac */
void CStdIdentity::RemovedFromIDObj() { SetTheseStdIdFlags(0x2000); } /* 0x4c8c8, tail jmp */
void CStdIdentity::RevokeOID() /* 0x4c868 */
{
if (GetStdIdFlags() & 0x4) {
ClearTheseStdIdFlags(0x4);
if (!(GetStdIdFlags() & 0x8))
COIDTable::Remove(this->pIDObj_0xa0);
}
}
/* ---------- the two refcount primitives ---------------------------------------- */
/* 0x85204. Returns TRUE to exactly one caller: the one that observed the raw value 1.
*pOut receives the value that was stored. Note the comparison is against the RAW
dword, so an object already carrying bit 31 can never take the destroy branch. */
int InterlockedDecRefCnt(ULONG *pRefs, ULONG *pOut)
{
ULONG seen, store;
int destroy;
seen = Feature_FixNonAtomicAccessInCombaseAndRpcss::IsEnabled()
? _InterlockedCompareExchange(pRefs, 0, 0) /* interlocked load */
: *pRefs; /* plain load */
for (;;) {
if (seen == 1) { store = 0x80000000; destroy = 1; }
else { store = ((seen & 0x7fffffff) - 1) | (seen & 0x80000000);
destroy = 0; }
*pOut = store;
ULONG prev = _InterlockedCompareExchange(pRefs, store, seen);
if (prev == seen) return destroy;
seen = prev; /* CAS loop */
}
}
/* 0x19a4f4. The mirror, used when the destroy branch was taken but teardown was
refused because another thread held the object locked. */
int InterlockedRestoreRefCnt(ULONG *pRefs, ULONG *pOut);
/* ---------- AddRef: the aggregation split ---------------------------------------
CStdIdentity::AddRef is 19 bytes. It does no counting at all. It loads the
controlling unknown and tail-jumps to ITS AddRef. This is COM aggregation: when the
identity is aggregated, every AddRef on the outer identity belongs to the outer
object, and the identity's own count is untouched. "AddRef only sometimes" is the
documented design of the runtime, not a mistake. */
ULONG CStdIdentity::AddRef() /* 0x35410 */
{
IUnknown *outer = this->pUnkControl_0x158;
return outer->lpVtbl->AddRef(outer); /* vtable +0x08, guarded icall */
}
/* 0x84ef0. The inner unknown, reached when the identity controls itself. Recovers
`this` by subtracting the subobject offset. Only the server side counts here. */
ULONG CStdIdentity::CInternalUnk::AddRef()
{
CStdIdentity *id = (CStdIdentity *)((char *)this - 0x138);
if (!id->IsClient())
return _InterlockedExchangeAdd(&id->refs_0x148, 1) + 1; /* lock xadd */
/* client side: consult CStdMarshal::GetFlags() bit 13 and the TLS COleTls block
before counting, for proxy bookkeeping (0x84f29). */
...
}
/* ---------- Release: where destruction is decided -------------------------------- */
ULONG CStdIdentity::Release() /* 0x4b570, 901 bytes */
{
/* Branchless form of: is the controlling unknown our own inner unknown?
rax = (this + 0x138); rdx = rax + 8; rax = -rax; sbb; and
If pUnkControl is somebody else's, this identity is aggregated and the call
belongs to the outer object. Delegate and return. */
if (this->pUnkControl_0x158 != (IUnknown *)&this->internalUnk_0x138)
return this->pUnkControl_0x158->lpVtbl->Release(this->pUnkControl_0x158); /* 0x4b670 */
if (IsClient()) {
/* proxy-side checks: CStdMarshal::GetFlags() bits 13, 28, 29, 21, the
COleTls block at gs:[0x30]+0x1758, apartment id at +0x188, IsFreeThreaded,
IsAgileOOPProxy, IsFTM. A smuggled-proxy violation reports 0xdeaddead to
CoVrfNotifySmuggledProxy. None of this changes the count. */
}
ULONG stored;
if (!InterlockedDecRefCnt(&this->refs_0x148, &stored))
return stored & 0x7fffffff; /* not the last ref: done, 0x4b656 */
/* We are the thread that took 1 -> 0x80000000. Only we may destroy. */
if (CStdMarshal::GetFlags() & (1u << 11)) /* 0x4b6d0: skip teardown */
return stored & 0x7fffffff;
COleStaticMutexSem::Request(gLock); /* 0x4b6ec */
int mayDelete = 0;
if (this->refs_0x148 == 0x80000000) { /* nobody resurrected us */
if (IsLockedOrInDestructor()) {
this->refs_0x148 = 0x80000100; /* 0x4b8e9: hand off to the locker */
} else {
CIDObject *idobj = this->pIDObj_0xa0;
if (idobj == NULL || idobj->field_0x6c == 0) {
RevokeOID(); /* 0x4b722 */
if (idobj && !IsRemovedFromIDObj()) {
_bittestandreset(&idobj->field_0x28, 14);
idobj->field_0x60 = 0;
if (idobj->field_0x28 & 0x20)
COIDTable::Remove(idobj); /* 0x4b8dc */
if (idobj->field_0x58 == 0 && (idobj->field_0x28 & 0x10)) {
/* unlink from the intrusive list at +0x08 / +0x10 */
idobj->next->prev = idobj->prev;
idobj->prev->next = idobj->next;
gCount--;
idobj->field_0x28 &= ~0x10;
}
RemovedFromIDObj(); /* 0x4b77b */
}
mayDelete = 1;
}
}
}
COleStaticMutexSem::Release(gLock); /* 0x4b7d6 */
if (mayDelete) {
this->field_0x1b8 = savedArg;
CStdIdentity::`scalar deleting destructor'(this, /*flags*/ 1); /* 0x4b7f5 */
/* flag 1 = run the destructor AND free the memory. Synchronous, on this
thread, inside this call. Nothing is queued and nothing is deferred. */
} else {
InterlockedRestoreRefCnt(&this->refs_0x148, &stored); /* 0x4b82c */
}
return stored & 0x7fffffff;
}
/* 0x168a94. The other side of the 0x80000100 hand-off: the thread that held the lock
clears 0x200, republishes a count of 1, and releases through the controlling
unknown so the sequence above runs again cleanly. */
ULONG CStdIdentity::UnlockAndRelease()
{
ClearTheseStdIdFlags(0x200);
this->refs_0x148 = 1;
IUnknown *outer = this->pUnkControl_0x158;
return outer->lpVtbl->Release(outer); /* vtable +0x10 */
}
/* ---------- the process-lifetime counter ---------------------------------------- */
/* 0x92e10. This is the "server stays alive" counter. It is a plain dword under a
writer lock with a fixed threshold of zero. When it reaches zero the class cache is
walked and entries are flagged. It governs the SERVER PROCESS, never an object. */
ULONG CoReleaseServerProcess(void)
{
if (!(gFlags & 1)) return 0;
CClassCache::ValidateGlobalServerRefCount();
CRWLock::AcquireWriterLock(&gClassCacheLock, -1);
ULONG n = --gServerRefCount;
if (n == 0 && gSomething == NULL) {
gState |= 2;
/* walk 0x17 class-cache buckets, set bit 2 on matching entries */
}
CRWLock::ReleaseWriterLock(&gClassCacheLock);
return n;
}
Full annotated reconstruction in combase-lifetime.c (228 lines); the essential parts:
Object layout, recovered from the code
CStdIdentity + 0x008 CStdMarshal base subobject (arg to CStdMarshal::GetFlags)
+ 0x0a0 CIDObject *pIDObj (arg to COIDTable::Remove)
+ 0x138 CInternalUnk subobject (the inner unknown)
+ 0x148 ULONG refs <- THE reference count
+ 0x14c ULONG flags (legacy, non-atomic)
+ 0x150 ULONG flags (atomic, feature-gated)
+ 0x158 IUnknown *pUnkControl (controlling unknown)
refs encoding: bits 0..30 = count, bit 31 = destruction in progress
0x80000100 = last Release landed on a locked object
AddRef counts nothing
asm
?AddRef@CStdIdentity@@UEAAKXZ ; 0x35410 — 19 bytes, leaf, no .pdata entry
mov rcx, [rcx + 0x158] ; pUnkControl
mov rax, [rcx] ; its vtable
mov rax, [rax + 8] ; slot 1 = AddRef
jmp _guard_dispatch_icall ; tail-call, CFG-guarded
This is the answer to the original question. The model that "couldn't understand why it called addref only sometimes" was staring at COM aggregation. Release opens with the same test, written branchlessly:
asm
lea rax, [rcx + 0x138] ; &this->internalUnk
mov rcx, [rcx + 0x158] ; pUnkControl
lea rdx, [rax + 8]
neg rax ; sbb rax, rax ; and rax, rdx ; branchless null-guard
cmp rcx, rax
jne 0x18004b670 ; not self-controlled -> delegate to outer
So AddRef/Release pair on the controlling unknown, not on the pointer you happen to hold. "Sometimes" is the documented design, and it is recoverable in 19 bytes.
The destroy decision
asm
lea r14, [rdi + 0x148] ; &refs
call ?InterlockedDecRefCnt@@YAHPEAK0@Z
test eax, eax
jne 0x18004b6c7 ; TRUE only for the thread that saw raw 1
...
mov edx, 1 ; flag 1 = destruct AND free
call ??_GCStdIdentity@@UEAAPEAXI@Z ; scalar deleting destructor,
int InterlockedDecRefCnt(ULONG *pRefs, ULONG *pOut) /* 0x85204 */
{
ULONG seen = Feature_FixNonAtomicAccessInCombaseAndRpcss::IsEnabled
? _InterlockedCompareExchange(pRefs, 0, 0) /* interloc
: *pRefs; /* plain load */
for (;;) {
ULONG store; int destroy;
if (seen == 1) { store = 0x80000000; destroy = 1; } /* raw compare: an object
alre
can never re-enter here */
else { store = ((seen & 0x7fffffff) - 1) | (seen & 0x80000000);
*pOut = store;
ULONG prev = _InterlockedCompareExchange(pRefs, store, seen);
if (prev == seen) return destroy;
seen = prev;
}
}
Incidental find: GetStdIdFlags/Set/Clear/IsClient each read two different flag words depending on Feature_FixNonAtomicAccessInIpidtbl — an atomicity fix staged behind a
WIL velocity gate, shipping in the binary in both states.
Action sequences
Sequence B is the reproduction. Step 5 is where it dies, and the harness names it:
B. bug: cache hit returns a borrowed pointer, caller releases it
# action refs owned dead note
1 CoCreateInstanceEx 0 -> 1 1 urned reference
2 QueryInterface -> pFoo 1 -> 2 2 ddRef'd
3 Release(pFoo) 2 -> 1 1
4 return cached pFoo (no AddRef) 1 -> 1 2 no BUG: owned but not counted
5 Release(pFoo) 1 -> 0 1 yes USE AFTER FREE
6 Release(root) 0 -> 2147483647 0
=> VIOLATIONS: refcount is 2147483647; 1 underflow; destroyed while caller held a reference; AddRef+1=2 != Release=3
Sequence D reproduces the aggregation delegation straight from 0x35410 — three AddRefs on the inner identity, inner's own count never moves, outer takes all six calls.
The six invariants that pin correct behavior, all hard equalities, all licensed by the disassembly:
owned == 0 caller released everything it owned
refs == 0 count reached zero
destroyed == 1 the 1 -> 0x80000000 transition happened exac
underflows == 0 never released an uncounted reference
!useAfterFree never destroyed while a handle was outstanding
AddRef + 1 == Release the +1 is CoCreateInstanceEx's initial refer
PASS — every refcount defect is a deterministic failure, including unde
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment