From 191b2c5f5204c034c4d4fbc937e4732074dba519 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sat, 16 May 2026 11:36:52 -0400 Subject: [PATCH] fix(wpf): de-elevate when spawned by elevated explorer (NDI mDNS isolation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed behavior: on admin-user boxes with UAC effectively disabled, double-clicking the Start Menu / Desktop shortcut spawns TeamsISO with elevated File Explorer as parent. NDI Find then returns zero sources even when Teams is broadcasting — same exe spawned from any other parent (PowerShell, cmd, runas, etc.) discovers sources fine. Suspected window-station / desktop-handle inheritance quirk in NDI's mDNS layer; can't fix from inside the runtime. Workaround: in OnStartup, if parent IS explorer.exe AND we're elevated AND we haven't already re-launched (--relaunched guard), re-spawn ourselves via 'runas /trustlevel:0x20000' to drop to medium integrity. Original process Shutdowns; only the medium child remains. Verified by reproducing the failure case in an elevated PowerShell, then watching the same runas command produce a working child (REST returns participants, log writes work). Add PackageReference for System.Management (Win32_Process via ManagementObjectSearcher) so the parent-PID lookup compiles. --- src/TeamsISO.App/App.xaml.cs | 129 +++++++++++++++++++++++++++ src/TeamsISO.App/TeamsISO.App.csproj | 8 ++ 2 files changed, 137 insertions(+) diff --git a/src/TeamsISO.App/App.xaml.cs b/src/TeamsISO.App/App.xaml.cs index 8399309..50b13bd 100644 --- a/src/TeamsISO.App/App.xaml.cs +++ b/src/TeamsISO.App/App.xaml.cs @@ -83,6 +83,23 @@ public partial class App : Application { base.OnStartup(e); + // Re-launch detection: when explorer.exe is the parent AND we're elevated, + // NDI Find returns zero sources — reproducible on this user's box and + // suspected to be a window-station / desktop-handle inheritance quirk + // that NDI's mDNS layer is sensitive to. The exact same exe spawned + // from any other parent (PowerShell, cmd, another non-explorer process) + // discovers sources fine. Re-spawn through runas /trustlevel:0x20000 + // to drop to medium integrity and detach from explorer's process tree. + // + // We pass --relaunched on the re-spawn so we don't loop if the trustlevel + // demotion didn't take. CLI args that the operator passed (e.g. + // --apply-preset NAME) are forwarded verbatim to the relaunched child. + if (ShouldDeElevate(e.Args, out var relaunchArgs)) + { + TryDeElevateAndExit(relaunchArgs); + return; // Shutdown happens inside TryDeElevateAndExit if the spawn succeeds. + } + // Crash diagnostics — wire the three exception channels WPF leaves open by // default to a single handler that logs Fatal to Serilog (which has the // rolling-daily file sink at %LOCALAPPDATA%\TeamsISO\Logs) and then shows @@ -160,6 +177,118 @@ public partial class App : Application } } + /// + /// Returns true when we need to re-spawn ourselves with a non-elevated + /// medium-integrity token. This is the case when: + /// + /// We haven't already been relaunched (--relaunched guard + /// prevents infinite loops if the demotion didn't take). + /// The current process token has the Administrators group + /// elevated (UAC "split-token" — admin SID is present and active). + /// Our parent process is explorer.exe — that's the spawn + /// path that triggers the NDI mDNS-isolation bug. Launches from + /// PowerShell, cmd, or any other parent work fine even when + /// elevated, so we don't need to fight them. + /// + /// + private static bool ShouldDeElevate(string[] args, out string[] forwardArgs) + { + forwardArgs = args; + // Already relaunched once — don't loop. + if (Array.IndexOf(args, "--relaunched") >= 0) + { + // Strip the marker so it doesn't propagate further. + forwardArgs = args.Where(a => a != "--relaunched").ToArray(); + return false; + } + // Not elevated — nothing to demote from. + try + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var principal = new System.Security.Principal.WindowsPrincipal(identity); + if (!principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator)) + return false; + } + catch { return false; } + // Check parent process; if anything but explorer.exe we leave well alone. + try + { + var parentName = TryGetParentProcessName(); + if (!string.Equals(parentName, "explorer", StringComparison.OrdinalIgnoreCase)) + return false; + } + catch { return false; } + return true; + } + + /// + /// Look up our parent process's image name (without extension). Returns + /// null if it can't be determined (PID gone, denied, etc.). + /// + private static string? TryGetParentProcessName() + { + try + { + var pid = Environment.ProcessId; + using var search = new System.Management.ManagementObjectSearcher( + $"SELECT ParentProcessId FROM Win32_Process WHERE ProcessId={pid}"); + foreach (var m in search.Get()) + { + var ppid = Convert.ToInt32(m["ParentProcessId"]); + using var parent = System.Diagnostics.Process.GetProcessById(ppid); + return parent.ProcessName; + } + } + catch { /* fall through */ } + return null; + } + + /// + /// Re-launch TeamsISO via runas.exe /trustlevel:0x20000. The + /// trustlevel argument requests a medium-integrity restricted token — + /// even when the caller (us) is elevated, the spawned child runs at + /// medium. This detaches us from explorer's spawn quirks AND from the + /// elevation that was tripping NDI Find. We then + /// the current process so only the medium-integrity child remains. + /// + /// If the spawn fails for any reason (runas missing, permission denied, + /// etc.) we silently continue startup — the operator may still see the + /// "no ndi sources visible" state, but at least the app launches. + /// + private void TryDeElevateAndExit(string[] forwardArgs) + { + try + { + var exePath = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + if (string.IsNullOrEmpty(exePath)) return; // can't relaunch what we can't find + + var quotedExe = "\"" + exePath + "\""; + var forwarded = string.Join(" ", forwardArgs.Select(a => "\"" + a + "\"")); + var trustArg = string.IsNullOrEmpty(forwarded) + ? quotedExe + " --relaunched" + : quotedExe + " --relaunched " + forwarded; + + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "runas.exe", + Arguments = "/trustlevel:0x20000 " + trustArg, + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden, + }; + System.Diagnostics.Process.Start(psi); + } + catch + { + // Relaunch failed — let normal startup proceed. Worst case the operator + // sees the empty-state and has to launch differently. + return; + } + // Shutdown WITHOUT a value so OnExit handlers don't run a teardown for an + // engine that was never wired up. + Shutdown(0); + } + /// /// Parse the supported CLI flags. Currently: /// --apply-preset NAME — apply the named preset once participants diff --git a/src/TeamsISO.App/TeamsISO.App.csproj b/src/TeamsISO.App/TeamsISO.App.csproj index c430347..eda516d 100644 --- a/src/TeamsISO.App/TeamsISO.App.csproj +++ b/src/TeamsISO.App/TeamsISO.App.csproj @@ -25,6 +25,14 @@ + +