From 541058e34fa7259b14e8842245e854686037c4af Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Wed, 26 Aug 2026 14:40:08 +0200 Subject: [PATCH 01/12] dotfiles: a Windows 7 test-target setup script Provisioning a Windows 7 VM so it can be driven from the host by VBoxManage guestcontrol takes a handful of settings that are easy to forget and easy to get wrong, and every one of them here was learned by hitting it: - Windows Error Reporting's dialog blocks a guestcontrol call until the host's timeout. A harness that scores a timeout as a failure then invents bugs that do not exist, so DontShowUI is not a nicety. - The host watches the guest with controlvm screenshotpng, so a screen saver or a blanked monitor makes every screenshot useless. - Bulk transfer over the VirtualBox shared folder is far faster than a copyto per file, but the mapping is per-user and does not survive into a new interactive session. The readiness report is the other half of the point. A test run against a box with no printer, or no audio capture device, reads as "the software under test refused" when the truth is "this VM never had one". It reports DWM composition for the same reason: with composition off, SetWindowDisplayAffinity fails with error 8 for every value, so anything testing screen-capture protection is testing nothing -- and Windows 7 forces the Basic theme while the install is not activated, which is exactly the state a throwaway test VM is usually in. Two shapes in the DWM check are load-bearing and are commented as such. Under guestcontrol, redirecting PowerShell's stdout to a file (or capturing it with for /f) hangs the run until the host times out, so PowerShell prints the verdict and the batch does not capture it. And the if/else must stay on ONE line: split across two, PowerShell treats the file as an incomplete command and waits on stdin forever. Two plausible cheaper checks are also wrong here, measured, and are called out so nobody re-introduces them: dwm.exe keeps running with composition off, and HKCU\...\DWM\Composition is the stored preference rather than the live state. Both report ENABLED while the API returns false. The elevated half is skipped with a notice rather than attempted, because a guestcontrol-launched process gets a UAC-filtered token even for an account in Administrators and cannot elevate itself. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LLgzr9B13msJhCLpnmjNJS --- README.md | 1 + setup-windows-7-test-env.bat | 163 +++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 setup-windows-7-test-env.bat diff --git a/README.md b/README.md index dfe024e..6544500 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,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-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 diff --git a/setup-windows-7-test-env.bat b/setup-windows-7-test-env.bat new file mode 100644 index 0000000..376d4dd --- /dev/null +++ b/setup-windows-7-test-env.bat @@ -0,0 +1,163 @@ +@echo off +setlocal + +rem --------------------------------------------------------------------------- +rem setup-windows-7-test-env.bat - prepare a Windows 7 VM as a test target for +rem native Windows software driven from the host by VBoxManage guestcontrol. +rem +rem Copy this file into the guest and run it there. It is idempotent: run it +rem again after a rollback, or after any snapshot restore, to get the same box. +rem +rem Split in two halves, like the other provisioning scripts here: +rem - the per-user half runs as you and needs no UAC prompt +rem - the machine-wide half needs elevation and is SKIPPED (with a notice) +rem when this runs unelevated, so an unattended run never blocks on a prompt +rem +rem That split matters more than usual here: a guestcontrol-launched process gets +rem a UAC-filtered token even for an account in Administrators, so the elevated +rem half can only be done by running this from an interactive Administrator +rem prompt inside the guest. Everything the host harness actually needs is in +rem the per-user half. +rem --------------------------------------------------------------------------- + +echo( +echo ============================================================ +echo Windows 7 test-target setup +echo ============================================================ + +rem --- Are we elevated? "net session" is the cheapest reliable probe. --- +set "ELEVATED=0" +net session >nul 2>&1 +if "%ERRORLEVEL%"=="0" set "ELEVATED=1" + +rem === PER-USER HALF (no UAC needed) ========================================= + +echo( +echo [user] Suppressing crash/hard-error dialogs +rem A modal Windows Error Reporting dialog in the guest blocks a guestcontrol +rem call until its timeout: the run looks like a hang, and a harness that scores +rem a timeout as a failure will invent bugs that are not there. DontShowUI makes +rem a crashing test process die immediately and return its exit code instead. +reg add "HKCU\Software\Microsoft\Windows\Windows Error Reporting" /v DontShowUI /t REG_DWORD /d 1 /f >nul +if errorlevel 1 echo WARN: could not set DontShowUI + +echo [user] Disabling the screen saver and monitor blanking +rem The host watches this VM's screen with "VBoxManage controlvm screenshotpng". +rem A blanked screen makes every screenshot useless. +reg add "HKCU\Control Panel\Desktop" /v ScreenSaveActive /t REG_SZ /d 0 /f >nul +reg add "HKCU\Control Panel\Desktop" /v ScreenSaverIsSecure /t REG_SZ /d 0 /f >nul +reg add "HKCU\Control Panel\Desktop" /v SCRNSAVE.EXE /t REG_SZ /d "" /f >nul 2>&1 + +echo [user] Creating the staging directory C:\bb +if not exist "C:\bb" mkdir "C:\bb" +if not exist "C:\bb" echo WARN: could not create C:\bb + +echo [user] Mapping Z: to the VirtualBox shared folder "Downloads" +rem Bulk file transfer over a shared folder is far faster than a copyto per file. +rem The mapping is per-user and must be re-made in each new interactive session, +rem which is exactly why it lives in this script rather than in a host-side note. +if exist Z:\ goto :zdone +net use Z: \vboxsvr\Downloads /persistent:yes >nul 2>&1 +if errorlevel 1 echo WARN: could not map Z: - is the shared folder attached to this VM? +:zdone + +rem === MACHINE-WIDE HALF (needs elevation) =================================== + +if "%ELEVATED%"=="0" goto :skipadmin + +echo( +echo [admin] Suppressing the system hard-error dialog (ErrorMode=2) +reg add "HKLM\SYSTEM\CurrentControlSet\Control\Windows" /v ErrorMode /t REG_DWORD /d 2 /f >nul +if errorlevel 1 echo WARN: could not set ErrorMode + +echo [admin] Disabling sleep and monitor timeout on AC +powercfg -change -monitor-timeout-ac 0 >nul 2>&1 +powercfg -change -standby-timeout-ac 0 >nul 2>&1 +powercfg -change -disk-timeout-ac 0 >nul 2>&1 +powercfg -change -hibernate-timeout-ac 0 >nul 2>&1 + +echo [admin] Turning off Windows Update automatic install +rem An update reboot in the middle of a test run destroys the run and, worse, +rem silently changes the system under test between two comparable results. +reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update" /v AUOptions /t REG_DWORD /d 1 /f >nul 2>&1 +goto :adminend + +:skipadmin +echo( +echo [admin] SKIPPED - this process is not elevated. +echo Run this script from an Administrator prompt INSIDE the guest to +echo apply: ErrorMode=2, sleep/monitor timeouts, Windows Update policy. +echo A guestcontrol-launched process cannot elevate itself, so these +echo cannot be done from the host harness. +:adminend + +rem === READINESS REPORT ====================================================== +rem Everything below only reports. A test that silently runs against a box +rem missing a device reads as "the software under test refused" when the truth is +rem "this VM never had one" - so the harness must know, up front, what is here. + +echo( +echo ============================================================ +echo Readiness +echo ============================================================ +echo( +echo -- OS -- +ver +echo arch=%PROCESSOR_ARCHITECTURE% user=%USERNAME% elevated=%ELEVATED% + +echo( +echo -- Desktop Window Manager composition -- +rem SetWindowDisplayAffinity requires DWM composition. With composition OFF it +rem fails with error 8 for every value, so any screen-capture protection under +rem test is a silent no-op and its checks fail for a reason that has nothing to +rem do with the code. Windows 7 runs the Basic theme (composition off) whenever +rem the install is not activated. +rem +rem Ask the API, not a proxy for it. Two plausible-looking shortcuts are both +rem WRONG on this box, measured: dwm.exe keeps running with composition off, and +rem HKCU\...\DWM\Composition is the stored preference, not the live state. Both +rem say ENABLED while DwmIsCompositionEnabled returns false. +rem +rem Two shapes to keep, both learned the hard way against guestcontrol: +rem - PowerShell PRINTS the verdict; the batch does not capture it. Redirecting +rem its stdout to a file, or capturing through for /f, hangs the whole run +rem until the host's timeout fires. +rem - the if/else is ONE line. Split across two, PowerShell treats the file as +rem an incomplete command, waits on stdin and never exits. The "%DWMPS%" echo Add-Type -TypeDefinition @^" +>>"%DWMPS%" echo using System; +>>"%DWMPS%" echo using System.Runtime.InteropServices; +>>"%DWMPS%" echo public class D { [DllImport("dwmapi.dll")] public static extern int DwmIsCompositionEnabled(out bool e); } +>>"%DWMPS%" echo ^"@ +>>"%DWMPS%" echo $e = $false +>>"%DWMPS%" echo [void][D]::DwmIsCompositionEnabled([ref]$e) +>>"%DWMPS%" echo if ($e) { " composition = ENABLED - screen-capture affinity is testable." } else { " composition = DISABLED - SetWindowDisplayAffinity fails with error 8 for every value, so a screen-capture-protection test here is testing nothing. Windows 7 forces the Basic theme while the install is not activated; activate it to test that path." } +powershell -NoProfile -ExecutionPolicy Bypass -File "%DWMPS%" nul 2>&1 + +echo( +echo -- Printers (a print test needs at least one) -- +rem wmic, not PowerShell: no quoting to get wrong, and it is in the base install. +wmic printer get Name,Default /format:table 2>nul | findstr /R /V "^$" +if errorlevel 1 echo NONE - print tests will report "could not create a printer DC" + +echo( +echo -- Audio capture devices (a microphone test needs at least one) -- +wmic sounddev get Name,Status /format:table 2>nul | findstr /R /V "^$" +if errorlevel 1 echo NONE - enable audio for this VM in its VirtualBox settings + +echo( +echo -- Shared folder -- +if exist Z:\ (echo Z: mapped) else (echo Z: NOT mapped) + +echo( +echo -- Staging directory -- +if exist C:\bb (echo C:\bb present) else (echo C:\bb MISSING) + +echo( +echo ============================================================ +echo Done. Re-run this after any snapshot restore. +echo ============================================================ +endlocal -- 2.48.2 From aa6eb7838922a67ac1c9d7169fa0bb5b58b17762 Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Tue, 1 Sep 2026 12:49:52 +0200 Subject: [PATCH 02/12] dotfiles: pin WiX to 5.0.2, the last release that owes no fee winget install WiXToolset.WiXCLI has no version selector, so it installed 7.0.0. From 6.0 onward the WiX package carries OSMFEULA.txt on top of the Microsoft Reciprocal License: an Open Source Maintenance Fee agreement charging a monthly fee to anyone using the prebuilt binaries as part of revenue-generating activity with annual gross revenue >= US$10,000. The fee buys the binaries; it is not a license fee and restricts nothing about what we package. MS-RL is file-scoped and never reached the MSIs WiX builds under any version. Pinning to 5.0.2 is about the invoice. Install it as a .NET global tool instead, which takes a --version, and add the .NET 10 SDK it needs. The MSBuild half still has to be pinned in each .wixproj; the comment says so where someone will look for it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012gCzbrN6p3emFUyufuJsyq --- setup-windows.bat | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/setup-windows.bat b/setup-windows.bat index cc12420..5dc46e6 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -7,17 +7,45 @@ @rem --- Non-admin (per-user) winget installs --- winget install Anthropic.ClaudeCode winget install Git.Git +winget install Microsoft.DotNet.SDK.10 winget install Microsoft.PowerShell Microsoft.Sysinternals.ProcessExplorer Microsoft.Sysinternals.ProcessMonitor Microsoft.Sysinternals.SDelete Microsoft.VisualStudioCode Microsoft.WindowsTerminal winget install Oracle.VirtualBox winget install Python.Python.3.13 winget install WinMerge.WinMerge -winget install WiXToolset.WiXCLI @rem OpenCppCoverage: native (PE) line coverage for the C++ binaries. run-coverage-occ.py drives the @rem pytest suite under it to produce an HTML report (the binaries under test build with PDBs, @rem which it reads). The installer elevates via UAC. winget install OpenCppCoverage.OpenCppCoverage +@rem --- WiX 5.0.2, pinned on purpose --- +@rem WiX packages our proprietary software into MSIs, and the version is pinned to keep that +@rem free of a fee. 5.0.2 is the last release distributed under the Microsoft Reciprocal +@rem License alone. From 6.0 onward the package also carries OSMFEULA.txt, an Open Source +@rem Maintenance Fee agreement: a monthly fee owed by anyone who uses the PREBUILT BINARIES +@rem as part of revenue-generating activity and has annual gross revenue >= US$10,000. +@rem +@rem It is a fee for the binaries, not a restriction on what we ship - MS-RL is file-scoped +@rem and never reached the MSIs WiX builds, under any version - but 5.0.2 owes nothing. +@rem The 6.x/7.x SOURCE is still MS-RL too, so self-compiling is another way out; a pin is +@rem the cheaper one. This replaces `winget install WiXToolset.WiXCLI`, which has no version +@rem selector and so installs the latest (7.0.0 today, EULA and all). +@rem +@rem PIN THE MSBUILD SIDE TOO. A .wixproj referencing WixToolset.Sdk without a version +@rem resolves to the latest - 7.x, same EULA - and nothing here constrains it. Pin it in the +@rem project: . +@rem +@rem dotnet.exe is called by full path: winget put the SDK on the machine PATH a few lines +@rem ago, but this cmd session inherited its environment before that and cannot see it. +@rem install-then-update is for re-runs - install fails once the tool is there, and update +@rem then holds it at exactly 5.0.2 - which keeps this script idempotent like the rest. +set "DOTNET_EXE=%ProgramFiles%\dotnet\dotnet.exe" +"%DOTNET_EXE%" tool install --global wix --version 5.0.2 || "%DOTNET_EXE%" tool update --global wix --version 5.0.2 + +@rem Report what the pin actually produced. By full path again, and because the shim lands in +@rem a directory this session's PATH predates: expect "5.0.2+", not 7.x. +"%USERPROFILE%\.dotnet\tools\wix.exe" --version + @rem --------------------------------------------------------------------------- @rem No package manager needed for the Windows build @rem -- 2.48.2 From 7d4887fe245e7643a497e87e04fc8f97d8ca436f Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Fri, 28 Aug 2026 13:22:07 +0200 Subject: [PATCH 03/12] dotfiles: put vswhere.exe on the user PATH The Visual Studio installer drops vswhere.exe in %ProgramFiles(x86)%\Microsoft Visual Studio\Installer and nothing adds that directory to the PATH, so build scripts that locate VS with it - and VsDevCmd.bat itself - print "'vswhere.exe' is not recognized" on every run. New VsWhere step in the non-elevated half; it is skipped with a warning when no VS installer is present. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DhsGf8BtmQE1PtS1CAKns4 --- setup-windows-no-uac.ps1 | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/setup-windows-no-uac.ps1 b/setup-windows-no-uac.ps1 index f6500b8..1197300 100644 --- a/setup-windows-no-uac.ps1 +++ b/setup-windows-no-uac.ps1 @@ -12,6 +12,10 @@ What this installs / configures: - WinMerge on the user PATH + - vswhere.exe on the user PATH: the Visual Studio installer puts it in + %ProgramFiles(x86)%\Microsoft Visual Studio\Installer, which nothing adds + to the PATH, so build scripts (and VsDevCmd.bat itself) complain that + 'vswhere.exe' is not recognized - BinSkim (binary hardening analyzer) in %LOCALAPPDATA%\Programs\BinSkim, on the user PATH - Global git identity, and core.sshCommand pointed at a Win32-OpenSSH @@ -31,7 +35,7 @@ param( # for several, dot-call the script or use -Command: # .\setup-windows-no-uac.ps1 -Skip BinSkim,GitConfig # powershell -File .\setup-windows-no-uac.ps1 -Skip BinSkim - [ValidateSet('WinMerge', 'BinSkim', 'GitConfig')] + [ValidateSet('WinMerge', 'VsWhere', 'BinSkim', 'GitConfig')] [string[]] $Skip = @() ) @@ -90,6 +94,22 @@ function Add-WinMergeToUserPath { Add-ToUserPath $dir } +function Add-VsWhereToUserPath { + # vswhere.exe is how scripts locate Visual Studio (MSBuild, VsDevCmd.bat, + # the Windows SDK), and the VS installer drops it in a fixed directory that + # is never on the PATH. VsDevCmd.bat itself prints "'vswhere.exe' is not + # recognized" on every run without it. The directory is fixed by contract + # (32-bit Program Files, no version in the path), so there is nothing to + # search for: if it is missing, Visual Studio is not installed. + Write-Step 'vswhere on the user PATH' + $dir = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer' + if (-not (Test-Path (Join-Path $dir 'vswhere.exe'))) { + Write-Warning "vswhere.exe not found in $dir (no Visual Studio installer present); user PATH unchanged." + return + } + Add-ToUserPath $dir +} + function Install-BinSkim { # BinSkim checks the exact mitigations the native project enables in # CMakeLists.txt (CFG/XFG, CET, ASLR/HighEntropyVA, DEP, /GS, stack cookies, @@ -288,6 +308,7 @@ if (([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]:: $steps = [ordered]@{ WinMerge = { Add-WinMergeToUserPath } + VsWhere = { Add-VsWhereToUserPath } BinSkim = { Install-BinSkim } GitConfig = { Set-GlobalGitConfig } } -- 2.48.2 From 99725a30bdb3eee44c38e1fd9a28c060341aa058 Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Sat, 29 Aug 2026 18:43:11 +0200 Subject: [PATCH 04/12] 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 From 422ce8c735062050411d37ea8958eb3f71a42eea Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Sat, 29 Aug 2026 19:17:40 +0200 Subject: [PATCH 05/12] dotfiles: stop the UAC launch line mangling its own arguments Adding -TraceUser to the elevated launch gave it a second quoted argument, and the "".."" doubling used to get quotes through cmd only survives ONE. With two, the quote-state parsing merges the tail into the -File value, so the elevated PowerShell was handed -File "C:\...\setup-windows-with-uac.ps1 -TraceUser LATISLAB\Claude " and refused it -- "failed because the file does not have a '.ps1' extension" -- exiting -196608 (0xFFFD0000) before Start-Transcript could run. The batch file then reported no elevated log and guessed at a cancelled UAC prompt, which is the one thing that had not happened. Both values now travel in the environment and the quotes the child needs are built as [char]34 inside PowerShell, so the command line in the batch file carries no quote characters of its own beyond the outer pair. Exercised through cmd against a probe script in a directory with a space in its name: -File binds, -TraceUser arrives as LATISLAB\Claude, and the child's exit code still propagates. Against the real script with -Verb RunAs dropped, it now gets as far as the #Requires elevation check, which is where a non-elevated run should stop. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YMh8i2QzkHNdE3MkKfcaT6 --- setup-windows.bat | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/setup-windows.bat b/setup-windows.bat index 1d32ada..250d4cb 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -61,10 +61,20 @@ set "DOTNET_EXE=%ProgramFiles%\dotnet\dotnet.exe" @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). +@rem +@rem The two values go through the environment, and the quotes the child needs +@rem around them are built in PowerShell as [char]34, so the command line below +@rem contains no embedded quote characters at all. Writing them inline as "".."" +@rem works for ONE argument and quietly breaks at two: the quote-state parsing +@rem swallows everything after the first into the -File value, and the elevated +@rem PowerShell dies with "failed because the file does not have a '.ps1' +@rem extension" and exit code -196608 (0xFFFD0000) before it can log a thing. set "UAC_LOG=%~dp0setup-windows-uac.log" if exist "%UAC_LOG%" del "%UAC_LOG%" +set "UAC_SCRIPT=%~dp0setup-windows-with-uac.ps1" +set "UAC_TRACE_USER=%USERDOMAIN%\%USERNAME%" -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" +powershell -NoProfile -Command "$q = [char]34; $p = Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',($q + $env:UAC_SCRIPT + $q),'-TraceUser',($q + $env:UAC_TRACE_USER + $q) -Wait -PassThru; exit $p.ExitCode" set "UAC_RC=%ERRORLEVEL%" @rem --- Surface the elevated session's output (its window has already closed) --- -- 2.48.2 From 0aec6243fab52a10889bd5052a4cf1496be80b0d Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Sat, 29 Aug 2026 19:30:32 +0200 Subject: [PATCH 06/12] dotfiles: give the kernel logger's own ACL to Performance Log Users The group membership and SeSystemProfilePrivilege were necessary and not sufficient. Measured on this box after signing in with both in place: xperf -start X -on Microsoft-Windows-Kernel-Process -> exit 0, trace written xperf -on base -> NT Kernel Logger: Access is denied. (0x5) wpr -start GeneralProfile -> 0x80070005 The user-mode session proves the group fixed session control, and wpr's error moving off "Failed to enable the policy to profile system performance" proves the privilege took. What is left is the kernel logger itself: it does not use ETW's default per-GUID descriptor, and the explicit one on SystemTraceControlGuid does not mention Performance Log Users. EventAccessQuery on that GUID returns access denied outright from the account, which is the tell. So add an ACE for the group with EventAccessControl (EventSecurityAddDACL, so the entries Windows relies on stay put), carrying the controller rights including TRACELOG_ACCESS_KERNEL_LOGGER -- the right that names this particular session. It goes to the group like the privilege does, keeping membership the single switch, and the log now records the resulting DACL. The step moves to the FRONT of the elevated script and gains -EtwRightsOnly, which runs it and exits. It is seconds of LSA and registry work, where a full run is dominated by three Visual Studio passes that take minutes with nothing to do -- and iterating on this needed a way to apply it without paying for those. `exit` inside the try still runs the finally, so the transcript is stopped and the log left readable by the non-elevated caller. The README now states the cost plainly: a member of that group can capture system-wide kernel traces, including paths and command lines from every account on the box. Exercised under Windows PowerShell 5.1: the script parses, the EtwAcl interop compiles, the rights mask reads 0x0FE1, and both EventAccessControl and EventAccessQuery return a clean "access denied" from a non-elevated shell rather than marshalling garbage. The grant itself still needs an elevated run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YMh8i2QzkHNdE3MkKfcaT6 --- README.md | 70 ++++--- setup-windows-with-uac.ps1 | 362 ++++++++++++++++++++++++------------- setup-windows.bat | 1 + 3 files changed, 287 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index aea4062..421e1fe 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, 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-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, and `-EtwRightsOnly` to do that step alone. | | `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 @@ -145,27 +145,45 @@ throwaway VM reachable from a Linux host. 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 + Three separate things are in the way, and all three have to be dealt with: + + 1. 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. + 2. Switching on the *kernel* provider needs the `SeSystemProfilePrivilege` user + right ("Profile system performance"), held by default only by + Administrators and `NT SERVICE\WdiServiceHost` — that is the one `wpr` + names. + 3. The kernel logger is not covered by that default descriptor. + `SystemTraceControlGuid` — the session both `xperf -on` and `wpr` drive — + carries an explicit one that does not mention Performance Log Users. With + 1 and 2 in place a user-mode session starts and the privilege is held, and + `xperf -on base` *still* answers "NT Kernel Logger: Access is denied" while + `wpr`'s error changes to a bare `0x80070005`; even reading that descriptor + comes back access-denied, which is the tell. So an ACE for the group is + added with `EventAccessControl`, `TRACELOG_ACCESS_KERNEL_LOGGER` included. + + The privilege and the ACE both go to the **group**, and the account then goes + into the group, so membership alone is the switch and 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. Worth being clear about the cost: a member of that group can + capture system-wide kernel traces — process, image, file and registry activity + across every account on the box, paths and command lines included. + + Two consequences worth knowing. A privilege and a group membership are both 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: + in for 1 and 2 — any new logon does it, and an `ssh` login into the box is the + quick way to check without dropping the desktop. (The ACE in 3 is machine + state, read when a session starts, so it applies immediately.) 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 @@ -174,6 +192,16 @@ throwaway VM reachable from a Linux host. Analysis never needed any of this — `wpa.exe` opens an existing `.etl` as a plain user. This is only about collection. + + The rights step runs **first** in the elevated half, and `-EtwRightsOnly` runs + it and nothing else. It is seconds of LSA and registry work where a full run is + dominated by the three Visual Studio passes, which take minutes even with + nothing to do: + + ```powershell + Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass', + '-File','\setup-windows-with-uac.ps1','-TraceUser','DOMAIN\user','-EtwRightsOnly' + ``` - 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 ff947c6..a546a0a 100644 --- a/setup-windows-with-uac.ps1 +++ b/setup-windows-with-uac.ps1 @@ -36,7 +36,14 @@ param( # # 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 = '' + [string] $TraceUser = '', + + # Do the ETW rights step and nothing else. That step is seconds of registry + # and LSA work with no downloads, where a full run is dominated by the three + # Visual Studio passes, which take minutes even when they have nothing to do. + # It is why the ETW step runs FIRST: -EtwRightsOnly is then just an early + # exit rather than a set of guards down the rest of the script. + [switch] $EtwRightsOnly ) $ErrorActionPreference = 'Stop' @@ -204,6 +211,66 @@ function Grant-AccountRight([string]$Sid, [string]$Right) { [LsaRights]::Add((Get-SidBytes $Sid), $Right) } +# --------------------------------------------------------------------------- +# ETW provider-GUID access control +# +# ETW keeps a security descriptor per provider GUID under +# HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security, and EventAccessControl is +# the documented way to edit one. Editing the registry value directly would work +# too - it is a self-relative SD in a REG_BINARY - but the API takes the SID and +# the rights mask and leaves the descriptor's shape to Windows. +# --------------------------------------------------------------------------- +function Initialize-EtwAclType { + if ('EtwAcl' -as [type]) { return } + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class EtwAcl +{ + // ULONG EventAccessControl(LPGUID, ULONG Operation, PSID, ULONG Rights, BOOLEAN AllowOrDeny) + [DllImport("advapi32.dll", SetLastError = true)] + public static extern uint EventAccessControl(ref Guid guid, uint operation, byte[] sid, + uint rights, [MarshalAs(UnmanagedType.U1)] bool allowOrDeny); + + // ULONG EventAccessQuery(LPGUID, PSECURITY_DESCRIPTOR, PULONG BufferSize) + [DllImport("advapi32.dll", SetLastError = true)] + public static extern uint EventAccessQuery(ref Guid guid, byte[] buffer, ref uint bufferSize); +} +'@ +} + +# The rights a session controller needs, from evntrace.h: +# 0x0001 WMIGUID_QUERY 0x0100 TRACELOG_ACCESS_KERNEL_LOGGER +# 0x0020 TRACELOG_CREATE_REALTIME 0x0200 TRACELOG_LOG_EVENT +# 0x0040 TRACELOG_CREATE_ONDISK 0x0400 TRACELOG_ACCESS_REALTIME +# 0x0080 TRACELOG_GUID_ENABLE 0x0800 TRACELOG_REGISTER_GUIDS +# TRACELOG_ACCESS_KERNEL_LOGGER is the one that names the NT Kernel Logger +# specifically; the rest are what any controller needs to create a session, +# write it to disk and enable providers on it. +$EtwControllerRights = 0x0FE1 + +function Grant-EtwGuidAccess([string]$Guid, [string]$Sid, [uint32]$Rights) { + Initialize-EtwAclType + $g = [Guid]$Guid + # Operation 2 = EventSecurityAddDACL: add one ACE and leave every existing + # one in place. EventSecuritySetDACL (0) would REPLACE the descriptor, which + # on the kernel logger means removing the entries Windows itself relies on. + $rc = [EtwAcl]::EventAccessControl([ref]$g, 2, (Get-SidBytes $Sid), $Rights, $true) + if ($rc -ne 0) { throw (New-Object System.ComponentModel.Win32Exception([int]$rc)) } +} + +function Get-EtwGuidSddl([string]$Guid) { + Initialize-EtwAclType + $g = [Guid]$Guid + $size = [uint32]0 + [void][EtwAcl]::EventAccessQuery([ref]$g, $null, [ref]$size) + if ($size -eq 0) { return $null } + $buf = New-Object byte[] $size + if ([EtwAcl]::EventAccessQuery([ref]$g, $buf, [ref]$size) -ne 0) { return $null } + return (New-Object System.Security.AccessControl.RawSecurityDescriptor($buf, 0)).GetSddlForm('Access') +} + 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 @@ -273,6 +340,175 @@ try { Start-Transcript -Path $LogFile -Force | Out-Null } catch {} try { +# --------------------------------------------------------------------------- +# ETW collection rights for an ordinary account +# +# Out of the box, xperf and wpr only work elevated. THREE separate things stand +# in a standard user's way, and each has its own error: +# +# 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 descriptor +# 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. +# +# 3. The kernel logger is not covered by that default descriptor. Its own GUID - +# SystemTraceControlGuid, the session both `xperf -on` and wpr drive - carries +# an explicit descriptor that does not mention Performance Log Users, so 1 and +# 2 are not enough by themselves. Measured on this box with both in place: a +# user-mode session starts (exit 0) and the account holds the privilege, and +# `xperf -on base` still answers "NT Kernel Logger: Access is denied" while +# wpr's error changes from the policy message above to a bare 0x80070005. +# Even READING that descriptor comes back access-denied, which is the tell. So +# add an ACE for the group with EventAccessControl; TRACELOG_ACCESS_KERNEL_LOGGER +# is the right that names this particular session. +# +# The privilege and the ACE both go to the GROUP, and the account then goes into +# the group: membership alone becomes the switch, and enabling the next account +# is one `net localgroup` away with no policy or registry edit. +# +# What this costs, stated plainly: a member of that group can capture +# system-wide kernel traces - process, image, file and registry activity across +# every account on the box, paths and command lines included. That is what the +# group is for, and it is the price of collecting a trace without a UAC prompt. +# +# 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 1 and 2 do not take 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. +# The ACE in 3 is machine state, read when a session is started, so that one +# applies immediately. +# +# 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 +# SystemTraceControlGuid: the NT Kernel Logger / system session that xperf -on +# and wpr both drive. Fixed by contract, from evntrace.h. +$SystemTraceControlGuid = '9e814aad-3204-11d2-9a82-006008a86939' +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 kernel logger's own descriptor --- + # Idempotent: adding the ACE for a SID that already has it rewrites the same + # entry. Kept in its own try so that a failure here still leaves the group + # membership below to be done - user-mode sessions work without it. + try { + Grant-EtwGuidAccess $SystemTraceControlGuid $PerfLogUsersSid $EtwControllerRights + Write-Host (" Granted Performance Log Users the controller rights (0x{0:X4}, TRACELOG_ACCESS_KERNEL_LOGGER included) on SystemTraceControlGuid" -f $EtwControllerRights) + $sddl = Get-EtwGuidSddl $SystemTraceControlGuid + if ($sddl) { Write-Host " kernel logger DACL is now $sddl" -ForegroundColor DarkGray } + } catch { + Write-Warning "Could not add the ACE on SystemTraceControlGuid: $($_.Exception.Message)" + Write-Warning 'xperf -on will keep answering "NT Kernel Logger: Access is denied."' + } + + # --- 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' +} + +if ($EtwRightsOnly) { + # `exit` inside the try still runs the finally below, so the transcript is + # stopped and the log is left readable by the non-elevated caller. + Write-Host "`n-EtwRightsOnly: skipping the installs." -ForegroundColor Green + exit 0 +} + + # --------------------------------------------------------------------------- # Base tools via winget # --------------------------------------------------------------------------- @@ -695,130 +931,6 @@ if ($WptDir) { } } -# --------------------------------------------------------------------------- -# 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 diff --git a/setup-windows.bat b/setup-windows.bat index 250d4cb..9dda32e 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -6,6 +6,7 @@ @rem --- Non-admin (per-user) winget installs --- winget install Anthropic.ClaudeCode +winget install Brave.Brave winget install Git.Git winget install Microsoft.DotNet.SDK.10 winget install Microsoft.PowerShell Microsoft.Sysinternals.ProcessExplorer Microsoft.Sysinternals.ProcessMonitor Microsoft.Sysinternals.SDelete Microsoft.VisualStudioCode Microsoft.WindowsTerminal -- 2.48.2 From d52d627830e8e04a7ccada4b0725474b91281656 Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Sat, 29 Aug 2026 19:38:00 +0200 Subject: [PATCH 07/12] dotfiles: let the group read the kernel logger ACL it was granted The rights mask handed to EventAccessControl was 0x0FE1 -- the WMI and TRACELOG rights and nothing else. The SYSTEM and Administrators entries on that GUID carry 0x120FFF, and the missing 0x120000 is READ_CONTROL and SYNCHRONIZE: without READ_CONTROL the group cannot read back the descriptor it was just added to, so EventAccessQuery answers "access denied" whether or not the grant landed, which makes it useless as the one cheap probe available from the unelevated account. Now 0x120FE1. Also corrected, in the step and the README: the ACE is machine state, and a logon does nothing for it. ETW reads these descriptors into a cache, so a reboot is what is expected to put it into effect -- the ACE is in the descriptor (D:...(A;;0xfe1;;;LU)) and xperf -on base is still denied from a fresh shell on the running system. A first run therefore wants both: a new logon for the group membership and the privilege, a reboot for this. Not yet confirmed: whether the reboot is in fact sufficient. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YMh8i2QzkHNdE3MkKfcaT6 --- README.md | 7 +++++-- setup-windows-with-uac.ps1 | 26 ++++++++++++++++++++------ setup-windows.bat | 1 + 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 421e1fe..9615def 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,11 @@ throwaway VM reachable from a Linux host. Two consequences worth knowing. A privilege and a group membership are both read into the access token **at logon**, so the account must sign out and back in for 1 and 2 — any new logon does it, and an `ssh` login into the box is the - quick way to check without dropping the desktop. (The ACE in 3 is machine - state, read when a session starts, so it applies immediately.) And this only + quick way to check without dropping the desktop. The ACE in 3 is machine state + instead, and a logon does nothing for it: ETW reads these descriptors into a + cache, so it takes a **reboot** — with the ACE written and readable, `xperf -on + base` was still denied from a fresh shell on the running system. Plan on both + on a first run. 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, diff --git a/setup-windows-with-uac.ps1 b/setup-windows-with-uac.ps1 index a546a0a..8e8e797 100644 --- a/setup-windows-with-uac.ps1 +++ b/setup-windows-with-uac.ps1 @@ -248,7 +248,12 @@ public static class EtwAcl # TRACELOG_ACCESS_KERNEL_LOGGER is the one that names the NT Kernel Logger # specifically; the rest are what any controller needs to create a session, # write it to disk and enable providers on it. -$EtwControllerRights = 0x0FE1 +# +# READ_CONTROL (0x20000) and SYNCHRONIZE (0x100000) go with them - the SYSTEM and +# Administrators entries on this GUID carry 0x120FFF. Without READ_CONTROL the +# group cannot read the descriptor back, which makes EventAccessQuery useless as +# a check on whether the grant landed: it answers "access denied" either way. +$EtwControllerRights = 0x120FE1 function Grant-EtwGuidAccess([string]$Guid, [string]$Sid, [uint32]$Rights) { Initialize-EtwAclType @@ -400,8 +405,12 @@ try { # For the same reason 1 and 2 do not take 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. -# The ACE in 3 is machine state, read when a session is started, so that one -# applies immediately. +# The ACE in 3 is machine state rather than token state, so a logon does nothing +# for it. ETW reads these descriptors into a cache, so a REBOOT is what is +# expected to put the change into effect: with the ACE written and readable in +# the descriptor, xperf -on base was still answering "Access is denied" from a +# fresh shell on the running system. So on a first run, plan on both - a new +# logon for 1 and 2, a reboot for 3. # # Analysis never needed any of this: wpa.exe opens an existing .etl as a plain # user. This step is only about collection. @@ -422,9 +431,13 @@ try { } # --- The kernel logger's own descriptor --- - # Idempotent: adding the ACE for a SID that already has it rewrites the same - # entry. Kept in its own try so that a failure here still leaves the group + # Safe to repeat: a second ACE for the same SID unions to the same access. + # Kept in its own try so that a failure here still leaves the group # membership below to be done - user-mode sessions work without it. + # + # ETW reads these descriptors out of the registry into a cache, so a REBOOT + # is what puts a change here into effect - not a new logon, which is what the + # group membership and the privilege need. Both, on a first run. try { Grant-EtwGuidAccess $SystemTraceControlGuid $PerfLogUsersSid $EtwControllerRights Write-Host (" Granted Performance Log Users the controller rights (0x{0:X4}, TRACELOG_ACCESS_KERNEL_LOGGER included) on SystemTraceControlGuid" -f $EtwControllerRights) @@ -489,7 +502,8 @@ try { } Write-Host '' - Write-Host " $target must sign out and back in before this takes effect." -ForegroundColor Yellow + Write-Host " $target must sign out and back in for the group and the privilege," -ForegroundColor Yellow + Write-Host ' and the box must be REBOOTED for the kernel logger ACE (ETW caches it).' -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 diff --git a/setup-windows.bat b/setup-windows.bat index 9dda32e..a42c118 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -8,6 +8,7 @@ winget install Anthropic.ClaudeCode winget install Brave.Brave winget install Git.Git +winget install Google.AndroidGPUInspector winget install Microsoft.DotNet.SDK.10 winget install Microsoft.PowerShell Microsoft.Sysinternals.ProcessExplorer Microsoft.Sysinternals.ProcessMonitor Microsoft.Sysinternals.SDelete Microsoft.VisualStudioCode Microsoft.WindowsTerminal winget install Oracle.VirtualBox -- 2.48.2 From 1e91b38f9f50d0f6e706c8f710ad6a77c974799e Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Sat, 29 Aug 2026 19:46:46 +0200 Subject: [PATCH 08/12] dotfiles: report Intel VTune rather than installing it The elevated half now prints whether VTune is on the box, its version and the path to vtune.exe, and prints the download page when it is not -- adding that the CPU is not Intel, when it is not. Not automated on purpose. The offline installer is a ~750 MB download from registrationcenter-download.intel.com/akdlm/IRC_NAS//, and that GUID is per-release with no "latest" redirect behind it, so every new build would mean editing a hard-coded link in a script whose whole point is running unattended on a fresh box. It also only earns its place on Intel silicon, since hardware event-based sampling reads Intel PMU counters. The unattended incantation is recorded in the comment and the README for anyone who does want to script it: -a --silent --cli --eula accept Detection reads the two Uninstall hives rather than probing a path, so it follows the install wherever it went, and the CLI path goes through the oneAPI `latest` junction so it stays right across upgrades. Exercised against the 2026.2.0 install on this box: reports the version and a vtune.exe that exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YMh8i2QzkHNdE3MkKfcaT6 --- README.md | 14 +++++++++++++ setup-windows-with-uac.ps1 | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/README.md b/README.md index 9615def..e0ef744 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,20 @@ throwaway VM reachable from a Linux host. msstore`); it is not installed here because the Store source needs an interactive, signed-in session, which the unattended elevated half does not have. +- **Intel VTune Profiler is reported, not installed.** The elevated half prints + whether it is on the box, its version, and the path to `vtune.exe`; if it is + missing it prints the download page instead (and says so if the CPU is not + Intel). Automating the install is not worth it here: the offline installer is a + ~750 MB download from a URL carrying a per-release GUID with no "latest" + redirect behind it, so every new build would mean editing a hard-coded link, + and it is only worth having on Intel silicon since hardware event-based + sampling reads Intel PMU counters. It does install unattended if you want it + scripted elsewhere: + + ```powershell + intel-vtune-_offline.exe -a --silent --cli --eula accept + ``` + - **Tracing without a UAC prompt.** `xperf` and `wpr` fail for a standard user in two different ways, because two different things are missing: diff --git a/setup-windows-with-uac.ps1 b/setup-windows-with-uac.ps1 index 8e8e797..34bfa8b 100644 --- a/setup-windows-with-uac.ps1 +++ b/setup-windows-with-uac.ps1 @@ -946,6 +946,46 @@ if ($WptDir) { } +# --------------------------------------------------------------------------- +# Intel VTune Profiler - reported, not installed +# +# Deliberately NOT automated, unlike everything above. The offline installer is +# a ~750 MB download from a URL carrying a per-release GUID +# (registrationcenter-download.intel.com/akdlm/IRC_NAS//intel-vtune-_offline.exe) +# with no "latest" redirect behind it, so every new build means editing a +# hard-coded link in here - and it is only worth having on Intel silicon, since +# hardware event-based sampling reads Intel PMU counters. Not a good trade for a +# script that has to keep working unattended on any box. +# +# So this step only reports. To install it, take the Windows offline installer +# from +# https://www.intel.com/content/www/us/en/developer/tools/oneapi/vtune-profiler-download.html +# and run it elevated; it installs unattended with +# intel-vtune-_offline.exe -a --silent --cli --eula accept +# --------------------------------------------------------------------------- +Write-Step 'Intel VTune Profiler (status only)' +$UninstallKeys = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' +) +$vtune = Get-ItemProperty $UninstallKeys -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -match 'VTune' } | + Select-Object -First 1 +if ($vtune) { + Write-Host " Installed: $($vtune.DisplayName.Trim()) $($vtune.DisplayVersion)" -ForegroundColor Green + # The oneAPI layout keeps a `latest` junction beside the versioned directory, + # so this path stays right across upgrades. + $VTuneCli = Join-Path $vtune.InstallLocation 'vtune\latest\bin64\vtune.exe' + if (Test-Path $VTuneCli) { Write-Host " CLI: $VTuneCli" } +} else { + Write-Host ' Not installed.' -ForegroundColor Yellow + Write-Host ' https://www.intel.com/content/www/us/en/developer/tools/oneapi/vtune-profiler-download.html' -ForegroundColor Yellow + $cpu = (Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1).Manufacturer + if ($cpu -and $cpu -notmatch 'Intel') { + Write-Host " (This CPU reports itself as '$cpu' - VTune's hardware event-based sampling wants Intel silicon.)" -ForegroundColor Yellow + } +} + # --------------------------------------------------------------------------- Write-Host "`nAll done." -ForegroundColor Green Write-Host 'If a reboot was flagged above, restart before opening VS or building drivers.' -- 2.48.2 From 5c01c42c07a9003596daec75d22f779e743f8e55 Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Sat, 29 Aug 2026 19:50:50 +0200 Subject: [PATCH 09/12] dotfiles: record what VTune gives a standard user Measured on this box as latislab\claude, unelevated: vtune -collect hotspots runs a full collection and finalization, exit 0. Hardware event-based sampling does not -- VTune warns "To collect microarchitecture performance insights, run the product as administrator" at the top of every unelevated run, and uarch-exploration and hotspots -knob sampling-mode=hw both fail. Which matches Intel's own documentation: a regular user on Windows gets the User-Mode Sampling analyses and nothing else. Worth writing down because of how the failure presents: "cannot recognize the processor", which reads like unsupported hardware and is not. This is a Kaby Lake i5-8350U and the sampling drivers (sepdrv5, sepdal, vtss) are installed and running; VTune identifies the PMU through them and cannot reach them unelevated. That last step is inference from the evidence rather than something confirmed - confirming it takes one elevated run. And unlike the ETW work in this branch there is no group to join: the Linux sampling driver can be handed to a vtune group, but on Windows the documented answer is to elevate. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YMh8i2QzkHNdE3MkKfcaT6 --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index e0ef744..1bdadca 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,18 @@ throwaway VM reachable from a Linux host. intel-vtune-_offline.exe -a --silent --cli --eula accept ``` + **Running it needs no elevation, but hardware sampling does.** A standard user + gets the User-Mode Sampling analyses — `vtune -collect hotspots` and threading + — and they work: measured here, collection and finalization, exit 0. Hardware + event-based sampling (`uarch-exploration`, `memory-access`, `hotspots -knob + sampling-mode=hw`) wants administrator, and VTune says so in a warning at the + top of every unelevated run. Note the failure it actually gives is *"cannot + recognize the processor"*, which reads like a hardware problem and is not one: + the drivers (`sepdrv5`, `sepdal`, `vtss`) are installed and running, and VTune + identifies the PMU through them. Unlike ETW there is no group to join for this + — the Linux driver can be handed to a `vtune` group, but on Windows the + documented answer is to run as administrator. + - **Tracing without a UAC prompt.** `xperf` and `wpr` fail for a standard user in two different ways, because two different things are missing: -- 2.48.2 From 593189b7ff9cfdb6f644796728a2a1368637235e Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Sat, 29 Aug 2026 20:16:55 +0200 Subject: [PATCH 10/12] dotfiles: keep the group membership, drop the grants that bought nothing The NT Kernel Logger is reserved for Administrators and LocalSystem, and no amount of permission granting reaches it. Measured on this box, all three in place at once and across a reboot -- the account in Performance Log Users, SeSystemProfilePrivilege granted to that group, and an explicit ACE giving the group TRACELOG_ACCESS_KERNEL_LOGGER on SystemTraceControlGuid, confirmed present in the descriptor afterwards: xperf -on base -> NT Kernel Logger: Access is denied. (0x5) wpr -start GeneralProfile -> Access is denied. (0x80070005) It is not a check an ACE overrides, and Microsoft documents Performance Log Users access as explicitly not extending to that session. So the privilege grant and the ACE go, along with the LSA and EventAccessControl interop that existed only to apply them -- roughly 280 lines, in a script that was getting long enough to notice. What stays is the part that works, and it does work: membership in Performance Log Users lets the account create and control ordinary ETW sessions, verified unelevated after the reboot -- xperf -start T -on Microsoft-Windows-Kernel-Process -f trace.etl -> exit 0 which was Access denied before. Enough to trace an application's own providers without a UAC prompt. Kernel and CPU-sampling traces are elevated work now, run deliberately with xperf, wpr or VTune from an Administrator prompt. The step and the README keep the negative result rather than quietly dropping it: it is a plausible-looking path that does not work, and the next person to try deserves to be told where it ends. The README also says how to undo the two grants on a box that ran the earlier revision -- they are still applied here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YMh8i2QzkHNdE3MkKfcaT6 --- README.md | 105 +++++----- setup-windows-with-uac.ps1 | 384 ++++++------------------------------- 2 files changed, 99 insertions(+), 390 deletions(-) diff --git a/README.md b/README.md index 1bdadca..e9b2005 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, 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, and `-EtwRightsOnly` to do that step alone. | +| `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 reports whether Intel VTune Profiler is present. It also puts one ordinary account into `Performance Log Users`, so it can run user-mode ETW sessions unelevated. Can also be run directly from an Administrator prompt — pass `-TraceUser DOMAIN\user` to name that account, and `-EtwRightsOnly` to do that step alone. | | `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 @@ -163,74 +163,59 @@ throwaway VM reachable from a Linux host. — the Linux driver can be handed to a `vtune` group, but on Windows the documented answer is to run as administrator. -- **Tracing without a UAC prompt.** `xperf` and `wpr` fail for a standard user in - two different ways, because two different things are missing: +- **User-mode ETW tracing without a UAC prompt — and the kernel logger's hard + limit.** Out of the box a standard user cannot start *any* event tracing + session, not even a user-mode one naming a single provider: ```text - xperf -on base -> NT Kernel Logger: Access is denied. (0x5) - wpr -start GeneralProfile -> Failed to enable the policy to profile system performance. + xperf -start T -on Microsoft-Windows-Kernel-Process -f trace.etl + -> Access is denied. (0x5) ``` - Three separate things are in the way, and all three have to be dealt with: - - 1. 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. - 2. Switching on the *kernel* provider needs the `SeSystemProfilePrivilege` user - right ("Profile system performance"), held by default only by - Administrators and `NT SERVICE\WdiServiceHost` — that is the one `wpr` - names. - 3. The kernel logger is not covered by that default descriptor. - `SystemTraceControlGuid` — the session both `xperf -on` and `wpr` drive — - carries an explicit one that does not mention Performance Log Users. With - 1 and 2 in place a user-mode session starts and the privilege is held, and - `xperf -on base` *still* answers "NT Kernel Logger: Access is denied" while - `wpr`'s error changes to a bare `0x80070005`; even reading that descriptor - comes back access-denied, which is the tell. So an ACE for the group is - added with `EventAccessControl`, `TRACELOG_ACCESS_KERNEL_LOGGER` included. - - The privilege and the ACE both go to the **group**, and the account then goes - into the group, so membership alone is the switch and 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. Worth being clear about the cost: a member of that group can - capture system-wide kernel traces — process, image, file and registry activity - across every account on the box, paths and command lines included. - - Two consequences worth knowing. A privilege and a group membership are both - read into the access token **at logon**, so the account must sign out and back - in for 1 and 2 — any new logon does it, and an `ssh` login into the box is the - quick way to check without dropping the desktop. The ACE in 3 is machine state - instead, and a logon does nothing for it: ETW reads these descriptors into a - cache, so it takes a **reboot** — with the ACE written and readable, `xperf -on - base` was still denied from a fresh shell on the running system. Plan on both - on a first run. 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 rights step runs **first** in the elevated half, and `-EtwRightsOnly` runs - it and nothing else. It is seconds of LSA and registry work where a full run is - dominated by the three Visual Studio passes, which take minutes even with - nothing to do: + Session control is checked against the security descriptor ETW keeps per + provider GUID under `HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security`, whose + default grants those rights to SYSTEM, Administrators, the service accounts and + `BUILTIN\Performance Log Users` — and to nobody else. So the elevated half puts + the account named by `-TraceUser` into that group, and the command above then + works unelevated; enabling another account later is just + `net localgroup "Performance Log Users" /add`. Membership is read into + the access token **at logon**, so sign out and back in first — any new logon + does it, and an `ssh` login into the box is the quick way to check without + dropping the desktop. + + **Kernel traces are not available this way, and cannot be made to be.** + `xperf -on base` and `wpr -start` drive the *NT Kernel Logger*, which is + reserved for Administrators and LocalSystem — Microsoft documents Performance + Log Users access as explicitly not extending to it. That was measured rather + than assumed, and the negative result is recorded here so nobody repeats the + experiment: with the account in the group, `SeSystemProfilePrivilege` ("Profile + system performance") granted to that group, and an explicit ACE giving the + group `TRACELOG_ACCESS_KERNEL_LOGGER` on `SystemTraceControlGuid` — all three + in place, across a reboot — `xperf -on base` still answered `NT Kernel Logger: + Access is denied. (0x5)`. It is not a check an ACE overrides. The privilege + grant and the ACE were dropped again rather than left on the box earning + nothing: CPU sampling and whole-system traces are elevated work, with `xperf`, + `wpr` or VTune from an Administrator prompt. + + Analysis was never affected — `wpa.exe` opens an existing `.etl` as a plain + user. This is only about collection. + + The step runs **first** in the elevated half, and `-EtwRightsOnly` runs it and + nothing else, which matters because a full run is dominated by the three Visual + Studio passes that take minutes even with nothing to do: ```powershell Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass', '-File','\setup-windows-with-uac.ps1','-TraceUser','DOMAIN\user','-EtwRightsOnly' ``` + + If you ran an earlier revision of this script, it left both of the grants above + on the box. Undo the privilege in `secpol.msc` > Local Policies > User Rights + Assignment > "Profile system performance" by removing Performance Log Users. + The ACEs sit in the `{9e814aad-3204-11d2-9a82-006008a86939}` value under + `HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security`: strip the `LU` entries + from that descriptor rather than deleting the value, which also carries entries + for SYSTEM, Administrators and two service accounts. - 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 34bfa8b..17d02ee 100644 --- a/setup-windows-with-uac.ps1 +++ b/setup-windows-with-uac.ps1 @@ -17,9 +17,10 @@ - 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 + - Performance Log Users membership for one ordinary account, so it can run + user-mode ETW sessions (xperf -start ... -on ) without elevation. + Kernel traces are NOT covered - the NT Kernel Logger is admin-only; see the + step for what was measured. Change $VsInstallerUrl below to the Professional or Enterprise bootstrapper if needed: Professional : https://aka.ms/vs/17/release/vs_professional.exe @@ -27,22 +28,22 @@ #> 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. + # Account to put in Performance Log Users (see the "ETW session control" + # step, which runs first). 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). + # Pass an empty string to skip it; adding an account later is one + # `net localgroup` away. [string] $TraceUser = '', - # Do the ETW rights step and nothing else. That step is seconds of registry - # and LSA work with no downloads, where a full run is dominated by the three - # Visual Studio passes, which take minutes even when they have nothing to do. - # It is why the ETW step runs FIRST: -EtwRightsOnly is then just an early - # exit rather than a set of guards down the rest of the script. + # Do the ETW step and nothing else. It is a group membership and no + # downloads, where a full run is dominated by the three Visual Studio + # passes, which take minutes even when they have nothing to do. It is why + # that step runs FIRST: -EtwRightsOnly is then just an early exit rather + # than a set of guards down the rest of the script. [switch] $EtwRightsOnly ) @@ -62,220 +63,6 @@ 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) -} - -# --------------------------------------------------------------------------- -# ETW provider-GUID access control -# -# ETW keeps a security descriptor per provider GUID under -# HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security, and EventAccessControl is -# the documented way to edit one. Editing the registry value directly would work -# too - it is a self-relative SD in a REG_BINARY - but the API takes the SID and -# the rights mask and leaves the descriptor's shape to Windows. -# --------------------------------------------------------------------------- -function Initialize-EtwAclType { - if ('EtwAcl' -as [type]) { return } - Add-Type -TypeDefinition @' -using System; -using System.Runtime.InteropServices; - -public static class EtwAcl -{ - // ULONG EventAccessControl(LPGUID, ULONG Operation, PSID, ULONG Rights, BOOLEAN AllowOrDeny) - [DllImport("advapi32.dll", SetLastError = true)] - public static extern uint EventAccessControl(ref Guid guid, uint operation, byte[] sid, - uint rights, [MarshalAs(UnmanagedType.U1)] bool allowOrDeny); - - // ULONG EventAccessQuery(LPGUID, PSECURITY_DESCRIPTOR, PULONG BufferSize) - [DllImport("advapi32.dll", SetLastError = true)] - public static extern uint EventAccessQuery(ref Guid guid, byte[] buffer, ref uint bufferSize); -} -'@ -} - -# The rights a session controller needs, from evntrace.h: -# 0x0001 WMIGUID_QUERY 0x0100 TRACELOG_ACCESS_KERNEL_LOGGER -# 0x0020 TRACELOG_CREATE_REALTIME 0x0200 TRACELOG_LOG_EVENT -# 0x0040 TRACELOG_CREATE_ONDISK 0x0400 TRACELOG_ACCESS_REALTIME -# 0x0080 TRACELOG_GUID_ENABLE 0x0800 TRACELOG_REGISTER_GUIDS -# TRACELOG_ACCESS_KERNEL_LOGGER is the one that names the NT Kernel Logger -# specifically; the rest are what any controller needs to create a session, -# write it to disk and enable providers on it. -# -# READ_CONTROL (0x20000) and SYNCHRONIZE (0x100000) go with them - the SYSTEM and -# Administrators entries on this GUID carry 0x120FFF. Without READ_CONTROL the -# group cannot read the descriptor back, which makes EventAccessQuery useless as -# a check on whether the grant landed: it answers "access denied" either way. -$EtwControllerRights = 0x120FE1 - -function Grant-EtwGuidAccess([string]$Guid, [string]$Sid, [uint32]$Rights) { - Initialize-EtwAclType - $g = [Guid]$Guid - # Operation 2 = EventSecurityAddDACL: add one ACE and leave every existing - # one in place. EventSecuritySetDACL (0) would REPLACE the descriptor, which - # on the kernel logger means removing the entries Windows itself relies on. - $rc = [EtwAcl]::EventAccessControl([ref]$g, 2, (Get-SidBytes $Sid), $Rights, $true) - if ($rc -ne 0) { throw (New-Object System.ComponentModel.Win32Exception([int]$rc)) } -} - -function Get-EtwGuidSddl([string]$Guid) { - Initialize-EtwAclType - $g = [Guid]$Guid - $size = [uint32]0 - [void][EtwAcl]::EventAccessQuery([ref]$g, $null, [ref]$size) - if ($size -eq 0) { return $null } - $buf = New-Object byte[] $size - if ([EtwAcl]::EventAccessQuery([ref]$g, $buf, [ref]$size) -ne 0) { return $null } - return (New-Object System.Security.AccessControl.RawSecurityDescriptor($buf, 0)).GetSddlForm('Access') -} - 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 @@ -346,109 +133,48 @@ try { Start-Transcript -Path $LogFile -Force | Out-Null } catch {} try { # --------------------------------------------------------------------------- -# ETW collection rights for an ordinary account +# ETW session control for an ordinary account # -# Out of the box, xperf and wpr only work elevated. THREE separate things stand -# in a standard user's way, and each has its own error: +# Creating or controlling an 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. Its own description says members "may ... enable trace providers, +# and collect event traces", and that is what membership buys: # -# xperf -on base -> "NT Kernel Logger: Access is denied. (0x5)" -# wpr -start GeneralProfile -# -> "Failed to enable the policy to profile system -# performance." (0xc5585011) +# xperf -start MySession -on Microsoft-Windows-Kernel-Process -f trace.etl +# xperf -stop MySession # -# 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 descriptor -# 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". +# runs unelevated for a member and is "Access is denied. (0x5)" for everyone +# else. Enough to trace your own application's providers without a UAC prompt. # -# 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. +# Membership is read into the access token at LOGON, so 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. # -# 3. The kernel logger is not covered by that default descriptor. Its own GUID - -# SystemTraceControlGuid, the session both `xperf -on` and wpr drive - carries -# an explicit descriptor that does not mention Performance Log Users, so 1 and -# 2 are not enough by themselves. Measured on this box with both in place: a -# user-mode session starts (exit 0) and the account holds the privilege, and -# `xperf -on base` still answers "NT Kernel Logger: Access is denied" while -# wpr's error changes from the policy message above to a bare 0x80070005. -# Even READING that descriptor comes back access-denied, which is the tell. So -# add an ACE for the group with EventAccessControl; TRACELOG_ACCESS_KERNEL_LOGGER -# is the right that names this particular session. +# WHAT THIS DOES NOT BUY: system-wide kernel traces. `xperf -on base` and +# `wpr -start` drive the NT Kernel Logger, which is reserved for Administrators +# and LocalSystem - Microsoft documents Performance Log Users access as +# explicitly NOT extending to it. Measured here, so that nobody repeats it: with +# the account in the group, SeSystemProfilePrivilege ("Profile system +# performance") granted to that group, and an explicit ACE giving the group +# TRACELOG_ACCESS_KERNEL_LOGGER on SystemTraceControlGuid - all three in place, +# across a reboot - xperf still answered # -# The privilege and the ACE both go to the GROUP, and the account then goes into -# the group: membership alone becomes the switch, and enabling the next account -# is one `net localgroup` away with no policy or registry edit. +# xperf: error: NT Kernel Logger: Access is denied. (0x5). # -# What this costs, stated plainly: a member of that group can capture -# system-wide kernel traces - process, image, file and registry activity across -# every account on the box, paths and command lines included. That is what the -# group is for, and it is the price of collecting a trace without a UAC prompt. +# It is not a check an ACE overrides. Those two grants were dropped again rather +# than left on the box earning nothing, and CPU sampling and whole-system traces +# are elevated work: run xperf, wpr or VTune from an Administrator prompt. # -# 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 1 and 2 do not take 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. -# The ACE in 3 is machine state rather than token state, so a logon does nothing -# for it. ETW reads these descriptors into a cache, so a REBOOT is what is -# expected to put the change into effect: with the ACE written and readable in -# the descriptor, xperf -on base was still answering "Access is denied" from a -# fresh shell on the running system. So on a first run, plan on both - a new -# logon for 1 and 2, a reboot for 3. -# -# Analysis never needed any of this: wpa.exe opens an existing .etl as a plain -# user. This step is only about collection. +# Analysis needs none of this either way - wpa.exe opens an existing .etl as a +# plain user. # --------------------------------------------------------------------------- -Write-Step 'ETW collection rights (non-elevated xperf / wpr)' +Write-Step 'ETW session control (non-elevated user-mode tracing)' $PerfLogUsersSid = 'S-1-5-32-559' # BUILTIN\Performance Log Users -# SystemTraceControlGuid: the NT Kernel Logger / system session that xperf -on -# and wpr both drive. Fixed by contract, from evntrace.h. -$SystemTraceControlGuid = '9e814aad-3204-11d2-9a82-006008a86939' 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 kernel logger's own descriptor --- - # Safe to repeat: a second ACE for the same SID unions to the same access. - # Kept in its own try so that a failure here still leaves the group - # membership below to be done - user-mode sessions work without it. - # - # ETW reads these descriptors out of the registry into a cache, so a REBOOT - # is what puts a change here into effect - not a new logon, which is what the - # group membership and the privilege need. Both, on a first run. - try { - Grant-EtwGuidAccess $SystemTraceControlGuid $PerfLogUsersSid $EtwControllerRights - Write-Host (" Granted Performance Log Users the controller rights (0x{0:X4}, TRACELOG_ACCESS_KERNEL_LOGGER included) on SystemTraceControlGuid" -f $EtwControllerRights) - $sddl = Get-EtwGuidSddl $SystemTraceControlGuid - if ($sddl) { Write-Host " kernel logger DACL is now $sddl" -ForegroundColor DarkGray } - } catch { - Write-Warning "Could not add the ACE on SystemTraceControlGuid: $($_.Exception.Message)" - Write-Warning 'xperf -on will keep answering "NT Kernel Logger: Access is denied."' - } - - # --- 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. @@ -460,7 +186,7 @@ try { 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 'To do it later:' 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 @@ -502,16 +228,14 @@ try { } Write-Host '' - Write-Host " $target must sign out and back in for the group and the privilege," -ForegroundColor Yellow - Write-Host ' and the box must be REBOOTED for the kernel logger ACE (ETW caches it).' -ForegroundColor Yellow + 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 + Write-Host ' xperf -start T -on Microsoft-Windows-Kernel-Process -f trace.etl' -ForegroundColor Yellow + Write-Host ' xperf -stop T' -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 "Performance Log Users membership failed: $($_.Exception.Message)" + Write-Warning 'Do it by hand with:' Write-Warning ' net localgroup "Performance Log Users" /add' } -- 2.48.2 From ea83999a6597ec98e54525a0b832967c4308978c Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Tue, 1 Sep 2026 11:45:09 +0200 Subject: [PATCH 11/12] dotfiles: add iperf3 and nasm to the per-user winget installs Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012gCzbrN6p3emFUyufuJsyq --- setup-windows.bat | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup-windows.bat b/setup-windows.bat index a42c118..cb2765e 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -6,11 +6,13 @@ @rem --- Non-admin (per-user) winget installs --- winget install Anthropic.ClaudeCode +winget install ar51an.iPerf3 winget install Brave.Brave winget install Git.Git winget install Google.AndroidGPUInspector winget install Microsoft.DotNet.SDK.10 winget install Microsoft.PowerShell Microsoft.Sysinternals.ProcessExplorer Microsoft.Sysinternals.ProcessMonitor Microsoft.Sysinternals.SDelete Microsoft.VisualStudioCode Microsoft.WindowsTerminal +winget install NASM.NASM winget install Oracle.VirtualBox winget install Python.Python.3.13 winget install WinMerge.WinMerge -- 2.48.2 From 95307dbb8d8878588729ec7b6c71fc86f2150620 Mon Sep 17 00:00:00 2001 From: Max Vilimpoc Date: Tue, 1 Sep 2026 13:01:04 +0200 Subject: [PATCH 12/12] dotfiles: drop the non-elevated ETW tracing, it never worked Putting an ordinary account into Performance Log Users was supposed to buy user-mode ETW collection without a UAC prompt. Testing the actual workflow says otherwise: xperf and VTune want an administrator account and an elevated prompt, and everything short of that gets blocked somewhere. The kernel logger was already known to be admin-only; the rest turned out not to be worth the machinery either. So the -TraceUser and -EtwRightsOnly parameters, the group membership step, and the UAC launch line that existed only to carry an argument across the elevation boundary all come out. The launch line goes back to the single-argument form it had before. Kept, because they are useful whether or not collection is elevated: the Windows Performance Toolkit step that detects xperf/wpr/wpa, reports their versions and puts them on the machine PATH, and the VTune step that reports whether it is installed. The README keeps the negative result rather than quietly losing it, and gains the commands to undo what earlier revisions left on a box - the group membership, the privilege, and the ACE. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012gCzbrN6p3emFUyufuJsyq --- README.md | 83 +++++++++------------- setup-windows-with-uac.ps1 | 139 ------------------------------------- setup-windows.bat | 17 +---- 3 files changed, 33 insertions(+), 206 deletions(-) diff --git a/README.md b/README.md index e9b2005..7c98650 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, and reports whether Intel VTune Profiler is present. It also puts one ordinary account into `Performance Log Users`, so it can run user-mode ETW sessions unelevated. Can also be run directly from an Administrator prompt — pass `-TraceUser DOMAIN\user` to name that account, and `-EtwRightsOnly` to do that step alone. | +| `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 reports whether Intel VTune Profiler is present. Can also be run directly from an Administrator prompt. | | `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 @@ -151,71 +151,52 @@ throwaway VM reachable from a Linux host. intel-vtune-_offline.exe -a --silent --cli --eula accept ``` - **Running it needs no elevation, but hardware sampling does.** A standard user - gets the User-Mode Sampling analyses — `vtune -collect hotspots` and threading - — and they work: measured here, collection and finalization, exit 0. Hardware - event-based sampling (`uarch-exploration`, `memory-access`, `hotspots -knob - sampling-mode=hw`) wants administrator, and VTune says so in a warning at the - top of every unelevated run. Note the failure it actually gives is *"cannot + **Run it from an administrator account, elevated.** Hardware event-based + sampling (`uarch-exploration`, `memory-access`, `hotspots -knob + sampling-mode=hw`) requires it, and VTune warns about that at the top of every + unelevated run. Worth knowing that the failure it gives there is *"cannot recognize the processor"*, which reads like a hardware problem and is not one: the drivers (`sepdrv5`, `sepdal`, `vtss`) are installed and running, and VTune - identifies the PMU through them. Unlike ETW there is no group to join for this - — the Linux driver can be handed to a `vtune` group, but on Windows the + identifies the PMU through them. There is no group to join to get around it — + the Linux driver can be handed to a `vtune` group, but on Windows the documented answer is to run as administrator. -- **User-mode ETW tracing without a UAC prompt — and the kernel logger's hard - limit.** Out of the box a standard user cannot start *any* event tracing - session, not even a user-mode one naming a single provider: +- **Collect traces from an elevated Administrator session. Non-elevated + collection was tried here and abandoned.** The attempt was to put one ordinary + account into `BUILTIN\Performance Log Users`, which appears in the default + security descriptors ETW keeps per provider GUID under + `HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security`, and collect without a UAC + prompt. It does not survive contact with the real workflow: `xperf -on base` + and `wpr -start` drive the *NT Kernel Logger*, reserved for Administrators and + LocalSystem, and granting the group `SeSystemProfilePrivilege` ("Profile system + performance") plus an explicit ACE for `TRACELOG_ACCESS_KERNEL_LOGGER` on + `SystemTraceControlGuid` — all three in place, across a reboot — still answered ```text - xperf -start T -on Microsoft-Windows-Kernel-Process -f trace.etl - -> Access is denied. (0x5) + xperf: error: NT Kernel Logger: Access is denied. (0x5). ``` - Session control is checked against the security descriptor ETW keeps per - provider GUID under `HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security`, whose - default grants those rights to SYSTEM, Administrators, the service accounts and - `BUILTIN\Performance Log Users` — and to nobody else. So the elevated half puts - the account named by `-TraceUser` into that group, and the command above then - works unelevated; enabling another account later is just - `net localgroup "Performance Log Users" /add`. Membership is read into - the access token **at logon**, so sign out and back in first — any new logon - does it, and an `ssh` login into the box is the quick way to check without - dropping the desktop. - - **Kernel traces are not available this way, and cannot be made to be.** - `xperf -on base` and `wpr -start` drive the *NT Kernel Logger*, which is - reserved for Administrators and LocalSystem — Microsoft documents Performance - Log Users access as explicitly not extending to it. That was measured rather - than assumed, and the negative result is recorded here so nobody repeats the - experiment: with the account in the group, `SeSystemProfilePrivilege` ("Profile - system performance") granted to that group, and an explicit ACE giving the - group `TRACELOG_ACCESS_KERNEL_LOGGER` on `SystemTraceControlGuid` — all three - in place, across a reboot — `xperf -on base` still answered `NT Kernel Logger: - Access is denied. (0x5)`. It is not a check an ACE overrides. The privilege - grant and the ACE were dropped again rather than left on the box earning - nothing: CPU sampling and whole-system traces are elevated work, with `xperf`, - `wpr` or VTune from an Administrator prompt. - - Analysis was never affected — `wpa.exe` opens an existing `.etl` as a plain - user. This is only about collection. - - The step runs **first** in the elevated half, and `-EtwRightsOnly` runs it and - nothing else, which matters because a full run is dominated by the three Visual - Studio passes that take minutes even with nothing to do: + It is not a check an ACE overrides, and the same wall turned up often enough + elsewhere that the whole approach was dropped rather than carried as a + half-working path. **Sign in to an administrator account and run `xperf`, `wpr` + and VTune from an elevated prompt.** Analysis is the exception and never needed + any of this: `wpa.exe` opens an existing `.etl` as a plain user. + + If an earlier revision of these scripts ran on a box, it left that account in + the group. Take it back out with: ```powershell - Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass', - '-File','\setup-windows-with-uac.ps1','-TraceUser','DOMAIN\user','-EtwRightsOnly' + net localgroup "Performance Log Users" DOMAIN\user /delete ``` - If you ran an earlier revision of this script, it left both of the grants above - on the box. Undo the privilege in `secpol.msc` > Local Policies > User Rights - Assignment > "Profile system performance" by removing Performance Log Users. - The ACEs sit in the `{9e814aad-3204-11d2-9a82-006008a86939}` value under + Two revisions also granted the privilege and the ACE. Undo the privilege in + `secpol.msc` > Local Policies > User Rights Assignment > "Profile system + performance" by removing Performance Log Users. The ACEs sit in the + `{9e814aad-3204-11d2-9a82-006008a86939}` value under `HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security`: strip the `LU` entries from that descriptor rather than deleting the value, which also carries entries for SYSTEM, Administrators and two service accounts. + - 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 17d02ee..9f3d8ae 100644 --- a/setup-windows-with-uac.ps1 +++ b/setup-windows-with-uac.ps1 @@ -17,36 +17,12 @@ - Windows Driver Kit 10.0.26100 - Windows Performance Toolkit - xperf, wpr and Windows Performance Analyzer (wpa.exe) - on the machine PATH - - Performance Log Users membership for one ordinary account, so it can run - user-mode ETW sessions (xperf -start ... -on ) without elevation. - Kernel traces are NOT covered - the NT Kernel Logger is admin-only; see the - step for what was measured. 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 put in Performance Log Users (see the "ETW session control" - # step, which runs first). 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 it; adding an account later is one - # `net localgroup` away. - [string] $TraceUser = '', - - # Do the ETW step and nothing else. It is a group membership and no - # downloads, where a full run is dominated by the three Visual Studio - # passes, which take minutes even when they have nothing to do. It is why - # that step runs FIRST: -EtwRightsOnly is then just an early exit rather - # than a set of guards down the rest of the script. - [switch] $EtwRightsOnly -) - $ErrorActionPreference = 'Stop' function Write-Step([string]$Msg) { @@ -132,121 +108,6 @@ try { Start-Transcript -Path $LogFile -Force | Out-Null } catch {} try { -# --------------------------------------------------------------------------- -# ETW session control for an ordinary account -# -# Creating or controlling an 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. Its own description says members "may ... enable trace providers, -# and collect event traces", and that is what membership buys: -# -# xperf -start MySession -on Microsoft-Windows-Kernel-Process -f trace.etl -# xperf -stop MySession -# -# runs unelevated for a member and is "Access is denied. (0x5)" for everyone -# else. Enough to trace your own application's providers without a UAC prompt. -# -# Membership is read into the access token at LOGON, so 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. -# -# WHAT THIS DOES NOT BUY: system-wide kernel traces. `xperf -on base` and -# `wpr -start` drive the NT Kernel Logger, which is reserved for Administrators -# and LocalSystem - Microsoft documents Performance Log Users access as -# explicitly NOT extending to it. Measured here, so that nobody repeats it: with -# the account in the group, SeSystemProfilePrivilege ("Profile system -# performance") granted to that group, and an explicit ACE giving the group -# TRACELOG_ACCESS_KERNEL_LOGGER on SystemTraceControlGuid - all three in place, -# across a reboot - xperf still answered -# -# xperf: error: NT Kernel Logger: Access is denied. (0x5). -# -# It is not a check an ACE overrides. Those two grants were dropped again rather -# than left on the box earning nothing, and CPU sampling and whole-system traces -# are elevated work: run xperf, wpr or VTune from an Administrator prompt. -# -# Analysis needs none of this either way - wpa.exe opens an existing .etl as a -# plain user. -# --------------------------------------------------------------------------- -Write-Step 'ETW session control (non-elevated user-mode tracing)' -$PerfLogUsersSid = 'S-1-5-32-559' # BUILTIN\Performance Log Users -try { - # 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 'To do it later:' - 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 ' xperf -start T -on Microsoft-Windows-Kernel-Process -f trace.etl' -ForegroundColor Yellow - Write-Host ' xperf -stop T' -ForegroundColor Yellow - } -} catch { - Write-Warning "Performance Log Users membership failed: $($_.Exception.Message)" - Write-Warning 'Do it by hand with:' - Write-Warning ' net localgroup "Performance Log Users" /add' -} - -if ($EtwRightsOnly) { - # `exit` inside the try still runs the finally below, so the transcript is - # stopped and the log is left readable by the non-elevated caller. - Write-Host "`n-EtwRightsOnly: skipping the installs." -ForegroundColor Green - exit 0 -} - - # --------------------------------------------------------------------------- # Base tools via winget # --------------------------------------------------------------------------- diff --git a/setup-windows.bat b/setup-windows.bat index cb2765e..54d6956 100644 --- a/setup-windows.bat +++ b/setup-windows.bat @@ -60,25 +60,10 @@ 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). -@rem -@rem The two values go through the environment, and the quotes the child needs -@rem around them are built in PowerShell as [char]34, so the command line below -@rem contains no embedded quote characters at all. Writing them inline as "".."" -@rem works for ONE argument and quietly breaks at two: the quote-state parsing -@rem swallows everything after the first into the -File value, and the elevated -@rem PowerShell dies with "failed because the file does not have a '.ps1' -@rem extension" and exit code -196608 (0xFFFD0000) before it can log a thing. set "UAC_LOG=%~dp0setup-windows-uac.log" if exist "%UAC_LOG%" del "%UAC_LOG%" -set "UAC_SCRIPT=%~dp0setup-windows-with-uac.ps1" -set "UAC_TRACE_USER=%USERDOMAIN%\%USERNAME%" -powershell -NoProfile -Command "$q = [char]34; $p = Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File',($q + $env:UAC_SCRIPT + $q),'-TraceUser',($q + $env:UAC_TRACE_USER + $q) -Wait -PassThru; exit $p.ExitCode" +powershell -NoProfile -Command "$p = Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','""%~dp0setup-windows-with-uac.ps1""' -Wait -PassThru; exit $p.ExitCode" set "UAC_RC=%ERRORLEVEL%" @rem --- Surface the elevated session's output (its window has already closed) --- -- 2.48.2