Last active
September 20, 2026 19:39
-
-
Save neon-sunset/ff4b2629c2c95b02f9b5cdddd4fb811b to your computer and use it in GitHub Desktop.
combase-lifetime-disasm.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* 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; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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