From 99725a30bdb3eee44c38e1fd9a28c060341aa058 Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Sat, 29 Aug 2026 18:43:11 +0200 Subject: [PATCH] dotfiles: let a standard account collect ETW traces Windows Performance Analyzer is not a Visual Studio component -- VS's own Performance Profiler is a different, .diagsession-based tool that cannot open an .etl -- it ships with xperf and wpr in the Windows Performance Toolkit, either as an optional Windows SDK feature (OptionId.WindowsPerformanceToolkit) or inside the ADK, which bundles the same toolkit. The WPT step already installed it; it now detects the toolkit DIRECTORY rather than just xperf.exe, prints the version of each tool it found (wpa.exe included, since that is the one people come looking for), and re-asserts the machine PATH entry -- comparing with the trailing backslash trimmed, because the toolkit's own installer writes one and a second spelling of the same directory is just noise. Collection is the half that did not work for an ordinary account, and it fails in two distinct ways because two distinct things are missing: xperf -on base -> NT Kernel Logger: Access is denied. (0x5) wpr -start GeneralProfile -> Failed to enable the policy to profile system performance. Controlling ANY event tracing session -- a user-mode one naming a single provider included, which is the case that shows this is not only about the kernel -- is checked against the security descriptor ETW keeps per provider GUID, whose default grants the session-control rights to SYSTEM, Administrators, the service accounts and BUILTIN\Performance Log Users, and to nobody else. Switching on the kernel/system provider on top of that needs SeSystemProfilePrivilege, held by default only by Administrators and NT SERVICE\WdiServiceHost, and that is the one wpr names in its error. So grant the privilege to the GROUP and put the account in the group: membership alone becomes the switch, and enabling the next account is one net localgroup away with no policy edit. LsaAddAccountRights rather than a secedit round-trip -- it adds exactly one right to exactly one SID and is a no-op when already held, where secedit re-applies every user right on the box to fix one of them. SeDebugPrivilege is deliberately not granted: neither CPU sampling nor walking stacks in your own processes needs it, and it is equivalent to handing out administrator. setup-windows.bat passes -TraceUser across the UAC boundary. Accepting that prompt with an administrator's credentials runs the elevated half AS that administrator, so it cannot otherwise tell whose box this is. Two limits, both documented at the step and in the README. A privilege and a group membership are read into the access token at LOGON, so the account has to sign out and back in -- any new logon does, and an ssh login into the box is the quick way to check without dropping the desktop. And this only helps a NON-ADMIN account: UAC hands an administrator a filtered token keeping five harmless privileges, so an admin's ordinary shell still cannot trace however the policy reads. Analysis was never affected; wpa.exe opens an existing .etl as a plain user. Exercised under Windows PowerShell 5.1, which is what the batch file launches: the script parses, the LSA interop compiles under the in-box CodeDom compiler, the SID marshalling round-trips S-1-5-32-559, and LsaOpenPolicy fails cleanly with "Access is denied" from a non-elevated shell. Get-LocalGroup -SID resolves the localised group name, and the toolkit detection finds the SDK's WPT and correctly reports its PATH entry as already present. The grants themselves are unverified: they need an elevated run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YMh8i2QzkHNdE3MkKfcaT6 --- README.md | 58 +++++- setup-windows-with-uac.ps1 | 375 +++++++++++++++++++++++++++++++++++-- setup-windows.bat | 7 +- 3 files changed, 421 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6544500..aea4062 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ throwaway VM reachable from a Linux host. | --- | --- | | `setup-windows.bat` | Entry point. Runs the winget installs, then launches the elevated half and prints its log, then runs the non-elevated script. | | `setup-windows-no-uac.ps1` | The non-elevated, per-user half: WinMerge and BinSkim on the user `PATH`, and the global git config (identity, plus `core.sshCommand`). Can also be run directly from an ordinary prompt. | -| `setup-windows-with-uac.ps1` | The elevated half, started via UAC by the batch file. Enables `ssh-agent`, installs the OpenSSH Client and Server capabilities and starts `sshd`, unpacks the `rsync-windows` release zip for this architecture (`rsync.exe` plus the `ssh.exe` it runs) into `C:\Tools\rsync` on the machine `PATH`, then installs Visual Studio 2022 Community with the required components, the WDK, and the Windows Performance Toolkit. Can also be run directly from an Administrator prompt. | +| `setup-windows-with-uac.ps1` | The elevated half, started via UAC by the batch file. Enables `ssh-agent`, installs the OpenSSH Client and Server capabilities and starts `sshd`, unpacks the `rsync-windows` release zip for this architecture (`rsync.exe` plus the `ssh.exe` it runs) into `C:\Tools\rsync` on the machine `PATH`, then installs Visual Studio 2022 Community with the required components, the WDK, and the Windows Performance Toolkit, and finally grants one ordinary account the rights to collect ETW traces without elevation. Can also be run directly from an Administrator prompt — pass `-TraceUser DOMAIN\user` to say who gets those rights. | | `setup-windows-7-test-env.bat` | Prepares a **Windows 7 VM** as a test target driven from the host by `VBoxManage guestcontrol`. Copy it into the guest and run it there; it is idempotent, so re-run it after any snapshot restore. The per-user half needs no UAC (crash-dialog suppression, no screen blanking, a staging directory, the shared folder on `Z:`); the machine-wide half is skipped with a notice unless run elevated inside the guest. It then reports what the box can actually test: DWM composition, printers, audio capture devices. | ## Usage @@ -118,6 +118,62 @@ throwaway VM reachable from a Linux host. (`.../releases/download//`) instead. - Visual Studio is installed in three labelled passes (base workload, Clang/LLVM, XP toolset) so a failure identifies which component group is responsible. +- **Windows Performance Analyzer is not part of Visual Studio.** VS has its own + Performance Profiler, which is a different, `.diagsession`-based tool and + cannot open an `.etl`. WPA ships with `xperf` and `wpr` in the Windows + Performance Toolkit, which exists in exactly two places: as an optional + *feature* of the Windows SDK (`OptionId.WindowsPerformanceToolkit`) and inside + the Windows ADK, which bundles the same toolkit. Whether the SDK install that + Visual Studio performs selects that feature varies by version, so the elevated + half **detects first** — `%ProgramFiles(x86)%\Windows Kits\10\Windows + Performance Toolkit`, its 64-bit twin, and the ADK location — and only falls + back to `winget install Microsoft.WindowsADK` when nothing is there. It then + re-asserts that directory on the machine `PATH` (the toolkit's own installer + usually does this, and the Start Menu gets *Windows Kits > Windows Performance + Toolkit* shortcuts for WPA and WPR). To install just the toolkit instead of the + whole ADK, run the standalone SDK setup with + `winsdksetup.exe /features OptionId.WindowsPerformanceToolkit /q`. A newer WPA + also exists in the Microsoft Store (`winget install --id 9N0W1B2BXGNZ --source + msstore`); it is not installed here because the Store source needs an + interactive, signed-in session, which the unattended elevated half does not + have. +- **Tracing without a UAC prompt.** `xperf` and `wpr` fail for a standard user in + two different ways, because two different things are missing: + + ```text + xperf -on base -> NT Kernel Logger: Access is denied. (0x5) + wpr -start GeneralProfile -> Failed to enable the policy to profile system performance. + ``` + + Creating or controlling *any* ETW session — even a user-mode one naming a + single provider — is checked against the security descriptor ETW keeps per + provider GUID under `HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security`, whose + default grants the session-control rights to SYSTEM, Administrators, the + service accounts and `BUILTIN\Performance Log Users`, and to nobody else. + Switching on the *kernel* provider on top of that additionally needs the + `SeSystemProfilePrivilege` user right ("Profile system performance"), held by + default only by Administrators and `NT SERVICE\WdiServiceHost` — that is the + one `wpr` names. So the elevated half grants the privilege to the **group** and + puts the account in the group; enabling another account afterwards is just + `net localgroup "Performance Log Users" /add`. `SeDebugPrivilege` is + deliberately *not* granted: CPU sampling and stack walks of your own processes + do not need it, and it is equivalent to handing out administrator. + + Two consequences worth knowing. Both a privilege and a group membership are + read into the access token **at logon**, so the account must sign out and back + in — any new logon does it, and an `ssh` login into the box is the quick way to + check without dropping the desktop. And this only helps a **non-admin** + account: UAC hands an administrator a filtered token carrying just five + harmless privileges, so an admin's ordinary shell still cannot trace however + the policy reads. Verify from the target account, unelevated: + + ```powershell + whoami /priv | findstr SeSystemProfilePrivilege + xperf -on base ; xperf -stop C:\Temp\trace.etl + ``` + + Analysis never needed any of this — `wpa.exe` opens an existing `.etl` as a + plain user. This is only about collection. - The scripts were extracted from a native Windows project, so the component selection is tuned for that: Spectre-mitigated runtimes, the v141/XP toolset, and driver-kit headers. Trim the component lists in the `.ps1` if you don't diff --git a/setup-windows-with-uac.ps1 b/setup-windows-with-uac.ps1 index 8d990f5..ff947c6 100644 --- a/setup-windows-with-uac.ps1 +++ b/setup-windows-with-uac.ps1 @@ -15,12 +15,30 @@ - Visual Studio 2022 Community (C++ desktop workload, Spectre libs, WDK VSIX, Win11 SDK 26100, Clang/LLVM, and the v141 + Windows XP targeting toolset) - Windows Driver Kit 10.0.26100 + - Windows Performance Toolkit - xperf, wpr and Windows Performance Analyzer + (wpa.exe) - on the machine PATH + - ETW collection rights for one ordinary account: Performance Log Users + membership plus the "Profile system performance" user right, so xperf and + wpr run WITHOUT elevation Change $VsInstallerUrl below to the Professional or Enterprise bootstrapper if needed: Professional : https://aka.ms/vs/17/release/vs_professional.exe Enterprise : https://aka.ms/vs/17/release/vs_enterprise.exe #> +param( + # Account to be granted non-elevated ETW collection rights (see the "ETW + # collection rights" step at the bottom). Defaults to the interactive + # console user, but setup-windows.bat passes it explicitly: with + # over-the-shoulder elevation THIS script runs as the administrator whose + # credentials went into the UAC prompt, not as the user who started the + # batch file, so $env:USERNAME here is the wrong answer. + # + # Pass an empty string to skip the group membership (the user right is still + # granted to the group, so adding an account later is one command). + [string] $TraceUser = '' +) + $ErrorActionPreference = 'Stop' function Write-Step([string]$Msg) { @@ -37,6 +55,155 @@ function Assert-ExitCode([int]$Code, [string]$Step) { } } +# --------------------------------------------------------------------------- +# User rights assignment (LSA account rights) +# +# Windows has no built-in cmdlet for "grant this SID this privilege". The two +# ways to script it are secedit (export the whole USER_RIGHTS area to an INF, +# edit one line, re-import) and the LSA API. The API is used here because it is +# surgical: LsaAddAccountRights adds exactly one right to exactly one SID and is +# a no-op when it is already held, where a secedit round-trip re-applies every +# user right on the box to fix one of them. The GUI equivalent, for a human, is +# secpol.msc > Local Policies > User Rights Assignment +# +# The type is compiled on first use; C# 5 only, since Windows PowerShell 5.1's +# Add-Type compiles with the in-box CodeDom compiler. +# --------------------------------------------------------------------------- +function Initialize-LsaRightsType { + if ('LsaRights' -as [type]) { return } + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class LsaRights +{ + [StructLayout(LayoutKind.Sequential)] + private struct LSA_UNICODE_STRING + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct LSA_OBJECT_ATTRIBUTES + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint LsaOpenPolicy(IntPtr systemName, + ref LSA_OBJECT_ATTRIBUTES objectAttributes, uint desiredAccess, out IntPtr policyHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint LsaAddAccountRights(IntPtr policyHandle, byte[] accountSid, + LSA_UNICODE_STRING[] userRights, uint countOfRights); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint LsaEnumerateAccountRights(IntPtr policyHandle, byte[] accountSid, + out IntPtr userRights, out uint countOfRights); + + [DllImport("advapi32.dll")] + private static extern uint LsaClose(IntPtr policyHandle); + + [DllImport("advapi32.dll")] + private static extern uint LsaFreeMemory(IntPtr buffer); + + [DllImport("advapi32.dll")] + private static extern int LsaNtStatusToWinError(uint status); + + private const uint POLICY_VIEW_LOCAL_INFORMATION = 0x00000001; + private const uint POLICY_CREATE_ACCOUNT = 0x00000010; + private const uint POLICY_LOOKUP_NAMES = 0x00000800; + + // Returned by LsaEnumerateAccountRights when the SID holds no rights at all, + // which is an empty list rather than an error. + private const uint STATUS_OBJECT_NAME_NOT_FOUND = 0xC0000034; + + private static IntPtr OpenPolicy() + { + LSA_OBJECT_ATTRIBUTES attrs = new LSA_OBJECT_ATTRIBUTES(); + attrs.Length = Marshal.SizeOf(typeof(LSA_OBJECT_ATTRIBUTES)); + IntPtr handle; + uint status = LsaOpenPolicy(IntPtr.Zero, ref attrs, + POLICY_VIEW_LOCAL_INFORMATION | POLICY_CREATE_ACCOUNT | POLICY_LOOKUP_NAMES, out handle); + if (status != 0) { throw new Win32Exception(LsaNtStatusToWinError(status)); } + return handle; + } + + public static string[] Get(byte[] sid) + { + IntPtr policy = OpenPolicy(); + try + { + IntPtr rights; + uint count; + uint status = LsaEnumerateAccountRights(policy, sid, out rights, out count); + if (status == STATUS_OBJECT_NAME_NOT_FOUND) { return new string[0]; } + if (status != 0) { throw new Win32Exception(LsaNtStatusToWinError(status)); } + try + { + string[] result = new string[count]; + int stride = Marshal.SizeOf(typeof(LSA_UNICODE_STRING)); + for (int i = 0; i < count; i++) + { + LSA_UNICODE_STRING s = (LSA_UNICODE_STRING)Marshal.PtrToStructure( + new IntPtr(rights.ToInt64() + (long)i * stride), typeof(LSA_UNICODE_STRING)); + result[i] = Marshal.PtrToStringUni(s.Buffer, s.Length / 2); + } + return result; + } + finally { LsaFreeMemory(rights); } + } + finally { LsaClose(policy); } + } + + public static void Add(byte[] sid, string right) + { + IntPtr policy = OpenPolicy(); + try + { + LSA_UNICODE_STRING[] rights = new LSA_UNICODE_STRING[1]; + rights[0].Buffer = Marshal.StringToHGlobalUni(right); + // Length counts BYTES and excludes the terminator; MaximumLength includes it. + rights[0].Length = (ushort)(right.Length * 2); + rights[0].MaximumLength = (ushort)(right.Length * 2 + 2); + try + { + uint status = LsaAddAccountRights(policy, sid, rights, 1); + if (status != 0) { throw new Win32Exception(LsaNtStatusToWinError(status)); } + } + finally { Marshal.FreeHGlobal(rights[0].Buffer); } + } + finally { LsaClose(policy); } + } +} +'@ +} + +function Get-SidBytes([string]$Sid) { + $s = New-Object System.Security.Principal.SecurityIdentifier($Sid) + $bytes = New-Object byte[] $s.BinaryLength + $s.GetBinaryForm($bytes, 0) + return ,$bytes +} + +function Get-AccountRight([string]$Sid) { + Initialize-LsaRightsType + return [LsaRights]::Get((Get-SidBytes $Sid)) +} + +function Grant-AccountRight([string]$Sid, [string]$Right) { + Initialize-LsaRightsType + [LsaRights]::Add((Get-SidBytes $Sid), $Right) +} + function Show-VsSetupLogs { # The VS Installer writes dd_*.log to the invoking user's %TEMP%. Because # this script runs elevated, that %TEMP% belongs to the elevated user and is @@ -448,30 +615,48 @@ if ($WdkInstalledRoot -and $WdkInstalledRoot -match [regex]::Escape($WdkVersion) } # --------------------------------------------------------------------------- -# Windows Performance Toolkit (xperf / WPA / wpr) -- ETW CPU + loader profiling, -# used by the perf/ measurement scripts. WPT is an OPTIONAL Windows SDK feature -# that the VS "Windows 11 SDK" component does NOT select, so a fresh box lacks it. -# The Windows ADK bundles WPT and winget owns the (versioned) download URL, so it -# is the most reliable source. Idempotent (skips if xperf is already present in -# either the SDK or ADK location) and non-fatal so it never aborts provisioning. -# Lighter alternative if you don't want the full ADK: install the Windows SDK's -# "Windows Performance Toolkit" optional feature via winsdksetup.exe /features -# OptionId.WindowsPerformanceToolkit. +# Windows Performance Toolkit: xperf, wpr, and Windows Performance Analyzer +# (wpa.exe) -- ETW CPU + loader profiling and the GUI that reads the traces. +# +# WPA is NOT a Visual Studio component and has no relationship to VS's own +# Performance Profiler (a separate, .diagsession-based tool that cannot open an +# .etl). It ships in exactly two places: as an optional FEATURE of the Windows +# SDK ("Windows Performance Toolkit", OptionId.WindowsPerformanceToolkit), and +# in the Windows ADK, which bundles the same toolkit. Whether the SDK install +# that Visual Studio performs happens to select that feature varies with the VS +# and SDK version - when it does, WPT lands in +# %ProgramFiles(x86)%\Windows Kits\10\Windows Performance Toolkit and the SDK +# puts that directory on the machine PATH itself - so this step DETECTS first +# and only falls back to installing the ADK (winget owns the versioned download +# URL, which makes it the reliable source) when nothing is there. That fallback +# is a large download; to install just the toolkit instead, run the standalone +# SDK setup with +# winsdksetup.exe /features OptionId.WindowsPerformanceToolkit /q +# +# There is also a newer WPA in the Microsoft Store (`winget install --id +# 9N0W1B2BXGNZ --source msstore`), which updates independently of the SDK. It is +# not installed here: the Store package needs an interactive, signed-in session, +# which is exactly what this elevated, unattended half does not have. +# +# Idempotent and non-fatal - it never aborts provisioning. # --------------------------------------------------------------------------- -Write-Step 'Windows Performance Toolkit (xperf / WPA)' -$wptRoots = @( - (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Windows Performance Toolkit\xperf.exe'), - (Join-Path $env:ProgramFiles 'Windows Kits\10\Windows Performance Toolkit\xperf.exe'), - (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Windows Performance Toolkit\xperf.exe') +Write-Step 'Windows Performance Toolkit (xperf / wpr / WPA)' +$WptDirs = @( + (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Windows Performance Toolkit'), + (Join-Path $env:ProgramFiles 'Windows Kits\10\Windows Performance Toolkit'), + (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Windows Performance Toolkit') ) -$xperf = $wptRoots | Where-Object { Test-Path $_ } | Select-Object -First 1 -if ($xperf) { - Write-Host " OK: WPT already present ($xperf)" -ForegroundColor Green +function Find-WptDir { $script:WptDirs | Where-Object { Test-Path (Join-Path $_ 'xperf.exe') } | Select-Object -First 1 } + +$WptDir = Find-WptDir +if ($WptDir) { + Write-Host " OK: WPT already present ($WptDir)" -ForegroundColor Green } else { try { winget install --id Microsoft.WindowsADK --exact --silent --disable-interactivity ` --accept-source-agreements --accept-package-agreements Write-Host ' Windows ADK (includes Windows Performance Toolkit) installed.' + $WptDir = Find-WptDir } catch { Write-Warning "WPT install failed: $($_.Exception.Message)" Write-Warning 'Install manually: winget install Microsoft.WindowsADK, or add the' @@ -479,6 +664,162 @@ if ($xperf) { } } +if ($WptDir) { + # Report what actually landed. wpa.exe is the piece people come looking for + # and it is the one that is absent if a trimmed toolkit ever shows up. + foreach ($tool in 'xperf.exe', 'wpr.exe', 'wpa.exe', 'wpaexporter.exe') { + $p = Join-Path $WptDir $tool + if (Test-Path $p) { + Write-Host " $tool $((Get-Item $p).VersionInfo.ProductVersion)" + } else { + Write-Warning "$tool is missing from $WptDir" + } + } + + # The WPT installer normally adds this to the machine PATH itself (and the + # Start Menu gets "Windows Kits > Windows Performance Toolkit" shortcuts for + # WPA and WPR). Re-assert it anyway: on the machine PATH rather than a user + # one so it also resolves for the non-interactive sshd sessions this box is + # driven through, which build their environment from the registry PATH. + # Compared trailing-backslash-insensitively - the installer's own entry has + # one, and adding a second spelling of the same directory is just noise. + $m = [Environment]::GetEnvironmentVariable('Path', 'Machine') + if (-not $m) { $m = '' } + $have = ($m -split ';') | Where-Object { $_.TrimEnd('\') -eq $WptDir.TrimEnd('\') } + if ($have) { + Write-Host " OK: $WptDir already in the machine PATH" + } else { + $new = if ($m.Trim()) { $m.TrimEnd(';') + ';' + $WptDir } else { $WptDir } + [Environment]::SetEnvironmentVariable('Path', $new, 'Machine') + Write-Host " Added $WptDir to the machine PATH (restart shells to pick it up)." + } +} + +# --------------------------------------------------------------------------- +# ETW collection rights for an ordinary account +# +# Out of the box, xperf and wpr only work elevated, and they fail in two +# different ways for a standard user - because two different things are missing: +# +# xperf -on base -> "NT Kernel Logger: Access is denied. (0x5)" +# wpr -start GeneralProfile +# -> "Failed to enable the policy to profile system +# performance." (0xc5585011) +# +# 1. Creating or controlling ANY event tracing session - even a user-mode one +# naming a single provider - is checked against the security descriptor ETW +# keeps per provider GUID under +# HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security. The default grants the +# session-control rights (TRACELOG_CREATE_ONDISK, TRACELOG_CREATE_REALTIME, +# TRACELOG_GUID_ENABLE, TRACELOG_LOG_EVENT) to SYSTEM, Administrators, the +# service accounts, and BUILTIN\Performance Log Users - and to nobody else. +# That group is the supported hook; its own description says members "may +# ... enable trace providers, and collect event traces". +# +# 2. Switching on the kernel/system trace provider on top of that needs the +# SeSystemProfilePrivilege user right ("Profile system performance"), held by +# default only by Administrators and NT SERVICE\WdiServiceHost. That is the +# one wpr names in its error, and the one xperf trips over for -on base. +# +# So grant the privilege to the GROUP and then put the account in the group: +# membership alone becomes the switch, and enabling the next account is one +# `net localgroup` away with no policy edit. +# +# Deliberately NOT granted: SeDebugPrivilege. xperf needs it for neither CPU +# sampling nor walking stacks in your own processes, and it is equivalent to +# handing out administrator. +# +# THIS ONLY HELPS A NON-ADMIN ACCOUNT. Both a privilege and a group membership +# are baked into the access token at LOGON, and UAC hands an administrator a +# filtered token that keeps just five harmless privileges - so an admin's +# ordinary shell still cannot trace, however the policy reads. Running as a +# standard user is what makes this work. +# +# For the same reason nothing here takes effect in an already-open session: the +# account has to sign out and back in. Any NEW logon does it - an ssh login into +# this box is one, which is the quick way to check without dropping the desktop. +# +# Analysis never needed any of this: wpa.exe opens an existing .etl as a plain +# user. This step is only about collection. +# --------------------------------------------------------------------------- +Write-Step 'ETW collection rights (non-elevated xperf / wpr)' +$PerfLogUsersSid = 'S-1-5-32-559' # BUILTIN\Performance Log Users +try { + # --- The user right, granted to the group --- + $existing = Get-AccountRight $PerfLogUsersSid + if ($existing -contains 'SeSystemProfilePrivilege') { + Write-Host ' OK: Performance Log Users already holds SeSystemProfilePrivilege' + } else { + Grant-AccountRight $PerfLogUsersSid 'SeSystemProfilePrivilege' + Write-Host ' Granted SeSystemProfilePrivilege ("Profile system performance") to Performance Log Users' + } + + # --- The membership --- + # Fall back to the console user when the caller did not name one: with + # over-the-shoulder elevation that is the person who started + # setup-windows.bat, which is who wants to trace. + $target = $TraceUser + if (-not $target) { + $target = (Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue).UserName + if ($target) { Write-Host " No -TraceUser given; using the console user $target" } + } + + if (-not $target) { + Write-Warning 'No account to add to Performance Log Users (pass -TraceUser DOMAIN\user).' + Write-Warning 'The user right is in place, so this is the only step left:' + Write-Warning ' net localgroup "Performance Log Users" DOMAIN\user /add' + } else { + # Resolve to a SID first: it validates the name, and it is what the + # membership check compares, so a member spelled ".\claude" in one place + # and "LATISLAB\claude" in another is still recognised as the same account. + $targetSid = (New-Object System.Security.Principal.NTAccount($target)).Translate( + [System.Security.Principal.SecurityIdentifier]) + + # By SID, never by name: "Performance Log Users" is localised, and + # Get-LocalGroup -SID is how this stays correct on a non-English box. + $group = Get-LocalGroup -SID $PerfLogUsersSid + + # Get-LocalGroupMember throws on a group holding a SID that no longer + # resolves (a known Windows 10 bug), so a failure to READ the membership + # must not stop us from writing it - fall through and let the add report. + $already = $false + try { + $already = @(Get-LocalGroupMember -SID $PerfLogUsersSid | + Where-Object { $_.SID.Value -eq $targetSid.Value }).Count -gt 0 + } catch { + Write-Host " (could not enumerate $($group.Name) members: $($_.Exception.Message))" -ForegroundColor DarkGray + } + + if ($already) { + Write-Host " OK: $target is already in $($group.Name)" + } else { + try { + Add-LocalGroupMember -SID $PerfLogUsersSid -Member $targetSid.Value + } catch { + # "already a member" is only reachable when the enumeration above + # failed, and is not an error. Matched on the type NAME rather + # than in a typed catch clause: catch types are resolved when the + # script is PARSED, before the LocalAccounts module has been + # autoloaded, so naming the type there is a parse error that + # would take the whole script down. + if ($_.Exception.GetType().Name -ne 'MemberExistsException') { throw } + } + Write-Host " Added $target to $($group.Name)" + } + + Write-Host '' + Write-Host " $target must sign out and back in before this takes effect." -ForegroundColor Yellow + Write-Host ' Then, from that account (NOT elevated):' -ForegroundColor Yellow + Write-Host ' whoami /priv | findstr SeSystemProfilePrivilege' -ForegroundColor Yellow + Write-Host ' xperf -on base ; xperf -stop C:\Temp\trace.etl' -ForegroundColor Yellow + } +} catch { + Write-Warning "ETW rights setup failed: $($_.Exception.Message)" + Write-Warning 'Grant them by hand: secpol.msc > Local Policies > User Rights Assignment >' + Write-Warning '"Profile system performance" > add Performance Log Users, then' + Write-Warning ' net localgroup "Performance Log Users" /add' +} + # --------------------------------------------------------------------------- Write-Host "`nAll done." -ForegroundColor Green Write-Host 'If a reboot was flagged above, restart before opening VS or building drivers.' diff --git a/setup-windows.bat b/setup-windows.bat index 5dc46e6..1d32ada 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -56,10 +56,15 @@ set "DOTNET_EXE=%ProgramFiles%\dotnet\dotnet.exe" @rem --- Elevated installs (VS2022, WDK, system tools) --- @rem The elevated script runs in its own window and logs to setup-windows-uac.log. @rem -PassThru + $p.ExitCode propagates its real exit code back through to ERRORLEVEL. +@rem +@rem -TraceUser passes YOU across the UAC boundary. Accepting that prompt with an +@rem administrator's credentials runs the elevated half AS that administrator, so +@rem it cannot see whose box this is; the account named here is the one it grants +@rem non-elevated ETW collection rights to (xperf / wpr without a UAC prompt). set "UAC_LOG=%~dp0setup-windows-uac.log" if exist "%UAC_LOG%" del "%UAC_LOG%" -powershell -NoProfile -Command "$p = Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','""%~dp0setup-windows-with-uac.ps1""' -Wait -PassThru; exit $p.ExitCode" +powershell -NoProfile -Command "$p = Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','""%~dp0setup-windows-with-uac.ps1""','-TraceUser','""%USERDOMAIN%\%USERNAME%""' -Wait -PassThru; exit $p.ExitCode" set "UAC_RC=%ERRORLEVEL%" @rem --- Surface the elevated session's output (its window has already closed) --- -- 2.48.2