using System.Runtime.InteropServices; namespace WslcApiDump; /// /// D13's open question, reduced to one run (docs/WINDOWS_PORT.md §13.2). /// /// The plan routes container enumeration, session/container reattach and the Terminal panel's pty /// to the service-internal COM interface `IWSLCSessionManager` (IID 82A7ABC8-…). Reading /// `wslc.idl` turned up a problem the plan assumed away: **it declares interfaces and no /// activatable class.** There is no CLSID in it, so `CoCreateInstance` has nothing to name, and /// the only registered coclasses anywhere in WSL's IDLs belong to the *compat* surface and to the /// WSL service proper. /// /// The likely answer is that one of those coclasses also implements the internal interface — COM /// objects routinely expose several — and `wslc.exe` simply QIs for it. That is a yes/no question, /// and everything downstream depends on it: /// /// * **yes** → write the internal arm (`ListContainers`, `OpenSessionByName`, `OpenContainer`, /// `ResizeTty`), and a broker restart stops being fatal to a running sandbox. /// * **no** → D13 needs reopening. The realistic alternatives are a durable host-side roster /// plus `wsl --shutdown` on restart, or revisiting the rejected `wslc.exe` arm for enumeration. /// /// Two stages, because they carry very different risk: /// /// * --internal activates each candidate class and QIs for the internal IIDs. It never /// calls a method, so **it cannot crash on a vtable mismatch** — and QI alone answers the /// entry-point question. /// * --internal-call additionally calls through the interface (`GetVersion`, then /// `ListSessions`). This one CAN take the process down if the real vtable differs from the /// IDL — which is itself a finding, and the reason it is opt-in rather than default. /// internal static class InternalComProbe { // The only registered coclasses in WSL's IDLs. wslc.idl contributes none — that is the // problem — so every candidate here comes from WSLCCompat.idl or wslservice.idl. private static readonly (string Name, Guid Clsid, string Source)[] Candidates = [ ("WSLCCompatSessionManager", new Guid("a9b7a1b9-0671-405c-95f1-e0612cb4ce8f"), "WSLCCompat.idl — what the SDK itself activates, so the likeliest host"), ("WSLCCompatSessionManagerFactory", new Guid("9fcd2067-9fc6-4efa-9eb0-698169ebf7d3"), "WSLCCompat.idl"), ("LxssUserSession", new Guid("a9b7a1b9-0671-405c-95f1-e0612cb4ce7e"), "wslservice.idl — the WSL service proper; note it differs from the compat CLSID " + "only in the last byte (ce7e vs ce8f)"), ("LxssUserSessionInBox", new Guid("4f476546-b412-4579-b64c-123df331e3d6"), "wslservice.idl"), ]; private static readonly (string Name, Guid Iid, string Why)[] Interfaces = [ ("IWSLCSessionManager", new Guid("82A7ABC8-6B50-43FC-AB96-15FBBE7E8760"), "THE one that matters — OpenSessionByName/EnterSession/ListSessions, i.e. reattach"), ("IWSLCSession", new Guid("EF0661E4-6364-40EA-B433-E2FDF11F3519"), "ListContainers/OpenContainer. EXPECTED to fail here, and its failure is not a gap: a " + "session is RETURNED BY the manager (OpenSessionByName/EnterSession/CreateSession), " + "not QI'd off it. Probed only to confirm the manager is a manager and not a " + "do-everything object"), ("IWSLCVirtualMachine", new Guid("B5E2D8F1-9A3C-4E6B-8D1F-7C4A2E9B6D3A"), "GetId → the VM GUID an AF_HYPERV bind needs. Per the IDL only a factory the SYSTEM " + "service owns can produce one, so this is EXPECTED to fail — probed anyway, " + "because §13.2 retracted a decision on that reading and it deserves confirming"), ]; internal static void Run(bool callThrough, string? sessionName = null) { InternalComProbe.sessionName = sessionName; Console.WriteLine(); Console.WriteLine("internal COM probe (docs/WINDOWS_PORT.md §13.2):"); Console.WriteLine(" wslc.idl declares no coclass, so the question is whether an EXISTING"); Console.WriteLine(" class answers a QI for the internal interfaces."); Console.WriteLine(); var initialized = CoInitializeEx(IntPtr.Zero, CoinitMultithreaded); // S_FALSE means already initialized on this thread; RPC_E_CHANGED_MODE means the runtime // picked the other apartment. Neither is fatal for an out-of-proc server. if (initialized < 0 && (uint)initialized != RpcEChangedMode) { Console.WriteLine($" CoInitializeEx failed: 0x{initialized:X8} — cannot probe"); return; } var bound = false; foreach (var (name, clsid, source) in Candidates) { Console.WriteLine($" {name} {{{clsid}}}"); Console.WriteLine($" ({source})"); var hr = CoCreateInstance(in clsid, IntPtr.Zero, ClsctxAll, in IidIUnknown, out var unknown); if (hr < 0) { Console.WriteLine($" activation failed: {Hresult(hr)}"); if ((uint)hr == ENoInterface) // Every COM object implements IUnknown, so this combination is impossible // unless the IID being passed is not IID_IUnknown. Say so loudly: the first // version of this probe did exactly that and reported a confident false // negative that read like a real finding about WSL. Console.WriteLine(" !! E_NOINTERFACE on an IUnknown activation is IMPOSSIBLE — " + "every COM object implements IUnknown. This probe is passing a wrong IID; " + "treat the whole run as void and fix it, do NOT record a finding."); // A class factory is allowed to refuse IUnknown-first activation, so a failure // here is not yet an answer: ask for each internal interface directly before // concluding anything. foreach (var (interfaceName, iid, _) in Interfaces) { var direct = iid; var dhr = CoCreateInstance(in clsid, IntPtr.Zero, ClsctxAll, in direct, out var instance); Console.WriteLine($" direct activation as {interfaceName}: " + (dhr >= 0 ? "**YES**" : Hresult(dhr))); if (dhr < 0) continue; if (interfaceName == "IWSLCSessionManager") { bound = true; if (callThrough) CallThrough(instance); } Marshal.Release(instance); } Console.WriteLine(); continue; } Console.WriteLine(" activated"); try { foreach (var (interfaceName, iid, why) in Interfaces) { var iidLocal = iid; var qi = Marshal.QueryInterface(unknown, in iidLocal, out var candidate); if (qi >= 0) { Console.WriteLine($" QI {interfaceName}: **YES**"); if (interfaceName == "IWSLCSessionManager") { bound = true; RaiseImpersonation(candidate); if (callThrough) CallThrough(candidate); } Marshal.Release(candidate); } else { Console.WriteLine($" QI {interfaceName}: no ({Hresult(qi)})"); foreach (var line in Wrap(why)) Console.WriteLine($" {line}"); } } } finally { Marshal.Release(unknown); } Console.WriteLine(); } Console.WriteLine(bound ? " RESULT: IWSLCSessionManager IS reachable. D13's internal arm has an entry point —\n" + " write it (ListContainers, OpenSessionByName, OpenContainer, ResizeTty)\n" + " and record the CLSID that answered in §13.2." : " RESULT: IWSLCSessionManager is NOT reachable from any known coclass. D13 needs\n" + " reopening — see §13.2 for the two alternatives. Re-run with\n" + " --internal-call only if a QI above succeeded; it adds nothing here."); if (!callThrough && bound) Console.WriteLine(" (pass --internal-call to also CALL through it — confirms the vtable " + "matches the IDL, and can crash if it does not)"); } /// /// Prove the vtable is really the IDL's, not just that the IID is recognised. A QI can succeed /// against an interface whose layout has since moved — `wslc.idl` says outright that breaking /// changes to it are fine — and the first thing that would tell us is a crash here rather /// than in the broker. /// private static void CallThrough(IntPtr manager) { Console.WriteLine(" calling through (vtable check):"); IWSLCSessionManager? proxy; try { proxy = (IWSLCSessionManager)Marshal.GetObjectForIUnknown(manager); } catch (Exception e) { Console.WriteLine($" could not build the RCW: {e.GetType().Name}: {e.Message}"); return; } try { var hr = proxy.GetVersion(out var version); Console.WriteLine(hr >= 0 ? $" GetVersion() = {version.Major}.{version.Minor}.{version.Revision} " + "— slot 3 matches the IDL" : $" GetVersion() failed: {Hresult(hr)}"); // Cross-check against the compat SDK's own GetVersion: the same service answering // both is what says these are two faces of one object rather than a coincidence. if (hr >= 0) Console.WriteLine(" compare with WslcService.GetVersion() above — they " + "should agree"); } catch (Exception e) { Console.WriteLine($" GetVersion() threw {e.GetType().Name}: {e.Message}"); return; } try { var hr = proxy.ListSessions(out var sessions, out var count); if (hr < 0) { Console.WriteLine($" ListSessions() failed: {Hresult(hr)}"); return; } Console.WriteLine($" ListSessions() = {count} session(s)"); for (var i = 0; i < count; i++) { var entry = Marshal.PtrToStructure( sessions + i * Marshal.SizeOf()); Console.WriteLine($" #{entry.SessionId} \"{entry.DisplayName}\" " + $"(creator pid {entry.CreatorPid})"); } // The callee allocated it; nobody else will free it. if (sessions != IntPtr.Zero) Marshal.FreeCoTaskMem(sessions); if (count == 0) // Worth saying, because "it returned S_OK" is weaker evidence than it looks: with // no entries, the WSLCSessionListEntry layout — two DWORDs then two INLINE // wchar_t buffers — was never actually unmarshalled. ListContainers uses the same // `size_is(, *Count)` double-pointer shape, so that layout is still unproven. Console.WriteLine(" (0 entries, so the ENTRY STRUCT layout is still " + "untested — run with a session up: --session --keep --internal-call)"); } catch (Exception e) { Console.WriteLine($" ListSessions() threw {e.GetType().Name}: {e.Message}"); return; } ProbeSessionHandoff(proxy); } /// /// Grant the server permission to impersonate us on this proxy. /// /// Found the hard way: `OpenSessionByName` failed `0x80070542` /// (`HRESULT_FROM_WIN32(1346)` = `ERROR_BAD_IMPERSONATION_LEVEL`) on a session that /// `ListSessions` had just listed by name. It is not a "missing" error at all — the service /// impersonates the caller to resolve a **per-user** session, and a .NET COM client is handed /// `RPC_C_IMP_LEVEL_IDENTIFY` by default, which lets the server check who we are but not act /// as us. `GetVersion` and `ListSessions` never impersonate, which is exactly why those two /// succeeded and made the failure look like a per-method capability gap. /// /// `CoSetProxyBlanket` is the surgical fix — it applies to this proxy only and works after /// marshalling has already happened. `nucleic-brokerd` can instead call `CoInitializeSecurity` /// once at startup, before its first COM call, which covers every proxy it will ever hold; /// that is the production shape, and it must come first or it fails `RPC_E_TOO_LATE`. /// private static void RaiseImpersonation(IntPtr proxy) { var hr = CoSetProxyBlanket( proxy, RpcCAuthnDefault, RpcCAuthzDefault, ColeDefaultPrincipal, RpcCAuthnLevelDefault, RpcCImpLevelImpersonate, ColeDefaultAuthinfo, EoacNone); Console.WriteLine(hr >= 0 ? " proxy blanket raised to RPC_C_IMP_LEVEL_IMPERSONATE" : $" CoSetProxyBlanket failed: {Hresult(hr)} — per-user calls will likely fail 0x80070542"); } /// /// The question that decides the SHAPE of D13's internal arm. /// /// `IWSLCSession` (internal, `EF0661E4-…`) and `IWSLCCompatSession` (SDK-facing, /// `DD7B2EF9-…`) are different interfaces with different IIDs. `WSLCCompatSessionManager` /// already proved one object can wear both faces — so if the session `OpenSessionByName` /// returns ALSO answers a QI for the compat interface, then a re-adopted session can be /// handed to `Session.FromAbi(ptr)` and driven by the **existing** facade code. /// /// * **YES** → internal COM is used only to *find* things. Reattach costs one QI, and /// nothing else in `WslcFacade` changes. /// * **NO** → every re-adopted handle is internal-only, and start/stop/delete/exec each need /// a second, hand-marshalled implementation. That is a much bigger arm, and it is the point /// at which the C++/WinRT shim §13.1 mused about earns its keep. /// private static void ProbeSessionHandoff(IWSLCSessionManager proxy) { var name = sessionName; if (string.IsNullOrEmpty(name)) { Console.WriteLine(" (no --session-name given; skipping the reattach handoff test)"); return; } Console.WriteLine(); Console.WriteLine($" reattach handoff test — OpenSessionByName(\"{name}\"):"); int hr; IntPtr session; try { hr = proxy.OpenSessionByName(name, out session); } catch (Exception e) { Console.WriteLine($" threw {e.GetType().Name}: {e.Message}"); return; } if (hr < 0) { Console.WriteLine($" failed: {Hresult(hr)}"); // Be specific about WHY, and never blame absence when the cause is security — an // earlier version printed "expected if no session of that name is running" directly // under a ListSessions that had just printed that session by name. Console.WriteLine((uint)hr switch { ErrorBadImpersonationLevel => " → NOT a missing session: the server could not impersonate us. If the " + "blanket was raised above and this still fails, the process needs " + "CoInitializeSecurity(RPC_C_IMP_LEVEL_IMPERSONATE) BEFORE its first COM call.", 0x8004060F => " → WSLC_E_SESSION_NOT_FOUND: no session of that name. Create one: " + "--session --keep --internal-call", _ => " → unexpected; compare against the ListSessions output above, which " + "says whether the session actually exists.", }); return; } Console.WriteLine(" opened — the session half of reattach WORKS"); try { var compat = IidCompatSession; var qi = Marshal.QueryInterface(session, in compat, out var projected); if (qi >= 0) { Console.WriteLine(" QI IWSLCCompatSession: **YES**"); Console.WriteLine(" → a re-adopted session can be handed to " + "Session.FromAbi() and driven by the EXISTING compat facade code."); Console.WriteLine(" → D13's internal arm shrinks to: find, then hand off."); ProbeFromAbiOwnership(projected); Marshal.Release(projected); } else { Console.WriteLine($" QI IWSLCCompatSession: no ({Hresult(qi)})"); Console.WriteLine(" → a re-adopted session is internal-only, so every " + "operation on it needs a second hand-marshalled path."); Console.WriteLine(" → that is the case where a C++/WinRT shim is worth it."); } } finally { Marshal.Release(session); } } private static string? sessionName; /// /// Does Session.FromAbi(ptr) take a reference, or borrow the caller's? /// /// This is not a detail — it is the difference between a leak and a use-after-free, and the /// facade will call it on every reattach. `QueryInterface` already handed us one reference; /// if `FromAbi` AddRefs as well we must `Release` ours, and if it merely wraps the pointer we /// must NOT. Neither CsWinRT's docs nor the IDL say which. /// /// Measured rather than assumed, by the only reliable means COM offers: `AddRef`/`Release` /// return the new count, so an AddRef/Release pair straddling the call reads the delta. /// The absolute numbers are meaningless (proxies keep their own counts); the DIFFERENCE is /// the answer. /// private static void ProbeFromAbiOwnership(IntPtr sessionPtr) { Console.WriteLine(); Console.WriteLine(" FromAbi ownership (leak vs. use-after-free):"); try { // Baseline: AddRef then Release, reading the count at the peak. var before = Marshal.AddRef(sessionPtr); Marshal.Release(sessionPtr); var projection = global::Microsoft.WSL.Containers.Session.FromAbi(sessionPtr); if (projection is null) { Console.WriteLine(" FromAbi returned null — cannot judge"); return; } var after = Marshal.AddRef(sessionPtr); Marshal.Release(sessionPtr); Console.WriteLine($" refcount {before} → {after} across FromAbi()"); Console.WriteLine(after > before ? " → FromAbi ADDS a reference. The facade must Release its QI reference " + "after handing the pointer over, or every reattach leaks the session." : " → FromAbi BORROWS the pointer. The facade must NOT Release its QI " + "reference — the projection depends on it staying alive."); // Prove the projection is actually usable, not just constructed: GetImages() is the // cheapest read on Session and mutates nothing. If this works, a re-adopted session // really is a first-class compat Session. try { var images = projection.GetImages(); Console.WriteLine($" projection.GetImages() = {images.Count} image(s) " + "— the re-adopted session is FULLY USABLE through the compat projection"); } catch (Exception e) { Console.WriteLine($" projection.GetImages() threw {e.GetType().Name}: " + $"{e.Message} — it projects but does not work; investigate before relying on it"); } } catch (Exception e) { Console.WriteLine($" FromAbi threw {e.GetType().Name}: {e.Message}"); } } private static IEnumerable Wrap(string text) { var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); var line = new System.Text.StringBuilder(); foreach (var word in words) { if (line.Length + word.Length + 1 > 88 && line.Length > 0) { yield return line.ToString(); line.Clear(); } if (line.Length > 0) line.Append(' '); line.Append(word); } if (line.Length > 0) yield return line.ToString(); } private static string Hresult(int code) => (uint)code switch { 0x80040154 => "REGDB_E_CLASSNOTREG — that class is not registered on this machine", 0x80004002 => "E_NOINTERFACE — the class does not implement it", 0x80070005 => "E_ACCESSDENIED — registered, but this token may not activate it", 0x800401F0 => "CO_E_NOTINITIALIZED", 0x80070542 => "ERROR_BAD_IMPERSONATION_LEVEL — the server needs to impersonate the caller " + "and this proxy only grants IDENTIFY. A SECURITY error, not a missing object", 0x80010119 => "RPC_E_TOO_LATE — CoInitializeSecurity after the first COM call", 0x8004060F => "WSLC_E_SESSION_NOT_FOUND", _ => $"0x{code:X8}", }; // MARK: - Interop /// /// `IID_IUnknown`, spelled out. **Do not** reach for `typeof(object).GUID` here — that is the /// CLR's type GUID for `System.Object`, not `IID_IUnknown`, and activating with it asks every /// class factory for an interface nothing implements. The failure is maximally misleading: /// each registered class answers `E_NOINTERFACE` *at activation*, which reads exactly like /// "this class does not expose the interface you wanted" and is really "you asked for /// gibberish". That mistake produced a false negative on this very probe. /// private static readonly Guid IidIUnknown = new("00000000-0000-0000-C000-000000000046"); /// `IWSLCCompatSession` from WSLCCompat.idl — the SDK-facing session interface, and the one /// the C#/WinRT `Session` projection wraps. Distinct from the internal `IWSLCSession` /// (`EF0661E4-…`); whether one object answers both is the reattach-handoff question. private static readonly Guid IidCompatSession = new("DD7B2EF9-AA01-4F21-8A3A-29D394CBB579"); private const uint ClsctxAll = 0x17; // INPROC_SERVER|HANDLER|LOCAL_SERVER|REMOTE_SERVER private const uint CoinitMultithreaded = 0; private const uint RpcEChangedMode = 0x80010106; private const uint ENoInterface = 0x80004002; private const uint ErrorBadImpersonationLevel = 0x80070542; // CoSetProxyBlanket's "keep the default" sentinels are -1, not 0 — passing 0 for the // principal name means "no principal" rather than "default" and fails differently. private const uint RpcCAuthnDefault = 0xFFFFFFFF; private const uint RpcCAuthzDefault = 0xFFFFFFFF; private const uint RpcCAuthnLevelDefault = 0; private const uint RpcCImpLevelImpersonate = 3; private const uint EoacNone = 0; private static readonly IntPtr ColeDefaultPrincipal = new(-1); private static readonly IntPtr ColeDefaultAuthinfo = new(-1); [DllImport("ole32.dll")] private static extern int CoSetProxyBlanket( IntPtr proxy, uint authnService, uint authzService, IntPtr serverPrincipalName, uint authnLevel, uint impersonationLevel, IntPtr authInfo, uint capabilities); [DllImport("ole32.dll")] private static extern int CoInitializeEx(IntPtr reserved, uint coInit); [DllImport("ole32.dll")] private static extern int CoCreateInstance( in Guid clsid, IntPtr outer, uint clsContext, in Guid iid, out IntPtr instance); [StructLayout(LayoutKind.Sequential)] private struct WslcVersion { public uint Major; public uint Minor; public uint Revision; } /// Matches `WSLCSessionListEntry` in wslc.idl: two 32-bit fields then two INLINE wide-char /// buffers (not pointers), which is why these are ByValTStr and why the sizes must be exact. [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct WslcSessionListEntry { public uint SessionId; public uint CreatorPid; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string DisplayName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 257)] public string Sid; } /// /// Declared only as far as ListSessions — but every preceding method must still be /// declared with the right parameter count, because a vtable is addressed by slot. The /// unused ones take IntPtr placeholders for their struct and interface pointers. /// PreserveSig throughout, so a failing HRESULT is a value to report rather than an /// exception to decode. /// [ComImport, Guid("82A7ABC8-6B50-43FC-AB96-15FBBE7E8760"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IWSLCSessionManager { [PreserveSig] int GetVersion(out WslcVersion version); [PreserveSig] int CreateSession(IntPtr settings, uint flags, IntPtr warningCallback, out IntPtr session); [PreserveSig] int EnterSession( [MarshalAs(UnmanagedType.LPWStr)] string displayName, [MarshalAs(UnmanagedType.LPWStr)] string storagePath, IntPtr warningCallback, out IntPtr session); [PreserveSig] int ListSessions(out IntPtr sessions, out uint count); [PreserveSig] int OpenSession(uint id, out IntPtr session); [PreserveSig] int OpenSessionByName( [MarshalAs(UnmanagedType.LPWStr)] string displayName, out IntPtr session); } }