17 minutes
Windows’ Performance Optimizer Is More Capable Than It Needs to Be
Earlier this month, a post on X went viral by claiming Microsoft had quietly slipped a spy into Windows 11:
MICROSOFT ADDED A NEW BACKGROUND SERVICE TO YOUR PC IN WINDOWS 11. It is called Windows Health and Optimized Experiences. It starts automatically every time you boot. It monitors your CPU, thermals, and battery then sends data to Microsoft every 15 minutes.
Enough people believed it that Microsoft’s Scott Hanselman replied personally:
This was added in 2025 and is for Performance-diagnostic capture: When Windows detects slow or sluggish behavior, it can record targeted performance traces locally under
%SystemRoot%\Temp\DiagOutputDir\Whesvcthat can be filed with Feedback Hub. It’s not laptop specific. SAYING THINGS IN ALL CAPS FOR DRAMA DOESN’T MAKE THEM DRAMATIC
He is right, and so are the fact-checks that followed on Neowin, PCWorld, and Windows Latest. The service is not new, it is not recording your screen, and the traces stay on your disk unless you file them yourself.
But it appears to me that nobody has really looked at the binary, and that is quite unsatisfying for a reverse engineer.
So I opened it (well, mostly with Binary Ninja MCP). It is a perfectly legitimate diagnostics service, and everything it ships does exactly what the name suggests. What surprised me is how it is built: whesvc runs a Lua interpreter as SYSTEM, with 84 compiled Lua scripts riding along, and the machinery underneath is considerably more powerful than the job seems to need.
Everything below is on Windows 11 Pro 26200, with service binaries 10.0.26100.8972. The tools I wrote are in the companion repo if you wish to follow along on your own machine.
Where Do I Start?
The service short name is whesvc, and at first glance it is entirely unremarkable:
Name : whesvc
DisplayName : Windows Health and Optimized Experiences
Description : Monitors the device for a better user experience
PathName : C:\WINDOWS\system32\svchost.exe -k whesvc -p
StartName : LocalSystem
StartMode : Auto (delayed)
It is a svchost service, so the interesting part is the ServiceDll, which points at C:\Windows\System32\whesvc.dll. That is only 229 KB, and public symbols are available for it, which makes the job much easier.
The entry point is HealthAndOptimizerService::StartWHEService, and it is short enough to read in one screen:
HealthAndOptimizerService::StartWHEService()
├─ RegGetValue(...\whesvc, "WaitForDebugger")
├─ SetProcessMitigationPolicy(ProcessRedirectionTrustPolicy, 1)
├─ InitializeAssetLoader() → LoadLibraryW(%systemroot%\system32\whesvc_assets.dll)
├─ WinDiag::Initialize() → GetSystemDirectoryW + \windiag.dll
├─ OrchestratorSingleton::Initialize()
├─ OrchestratorSingleton::RegisterOneSettingsConfigChangeWNF()
└─ StartWindiagModuleWithUri("builtin://scenario/init")
Three things caught my eye here. It loads two other DLLs I had never heard of, whesvc_assets.dll and windiag.dll. It registers for OneSettings configuration changes, so Microsoft can adjust its behavior remotely. And the last thing it does is start a module named by a URI, builtin://scenario/init.
That last one is unusual. Services do not normally have modules, and they certainly do not address them by URI. Something else is running the show here, and I will come back to what that module turns out to be.
In fact, whesvc.dll barely does any work at all. WinDiag::Initialize loads windiag.dll and resolves exactly three exports from it – WinDiagnosticsRequest, WinDiagnosticsResume, and WinDiagnosticsFree. That is the entire interface. So whatever this service really is, it lives in windiag.dll.
A Lua Interpreter in system32?
windiag.dll is 946 KB, and its strings answer the question immediately:
$LuaVersion: Lua 5.4.7 Copyright (C) 1994-2024 Lua.org, PUC-Rio $
I had not seen this before – Windows ships a Lua interpreter in system32, and a service runs it as SYSTEM.
To be fair, Lua is a sensible choice for this kind of job. It is tiny, it is fast, and embedding it is easy. That is exactly why games and embedded devices use it. It is still a little surprising to see Windows take on a new language dependency inside a system service.
Now, what about the other new DLL? whesvc_assets.dll turns out to be even stranger, because it contains no code at all. Its section table is just .rdata and .rsrc, there is no .text, and consequently there is no PDB for it either – there was nothing to compile. Everything is in the resources:
== TYPE LUALIBM == 61 resources (libraries)
== TYPE LUAMOD == 23 resources (scenarios)
== TYPE PROFILE == 5 resources (WPRP trace profile XML)
LUAMOD and LUALIBM are the Lua. So there are 84 scripts in here, and their names read like a table of contents: SCENARIO/HANG_TRACE, SCENARIO/SLOW_APP_LAUNCH, SCENARIO/NOISY_FAN, CORE/REG, CORE/SECURITY, MISC/ARTIFACT_MANAGER.
What Could the Scripts Do?
When you find a scripting engine inside a program, the bridge between the script and the native world is the half worth looking at first. It sets the ceiling: a script can never do more than the bridge allows.
Here the bridge is a global table called wdg, and every CORE/* library is a thin wrapper that re-exports part of it. So I wrote a small pass over the bytecode that tracks which registers hold that table and collects every field read from it. That gives 79 native functions.
Sixty of them are grouped into twelve libraries:
| Library | What it exposes |
|---|---|
core/reg |
Full registry read and write, all hives: create_key, delete_key, delete_value, set_*, get_*, enum_* |
core/file |
read_data, write_data, copy, move, remove, mkdir, rmdir, enum, and grep_init/grep_next for searching file contents |
core/security |
create_process, impersonate_process, revert_impersonation, token_info, code_integrity, process_protection |
core/etw |
Start and stop WPR traces, realtime sessions, provider and keyword control |
core/wmi |
wmi_query and wmi_method – Win32_Process, Win32_Service, Win32_NTLogEvent, MSFT_MpComputerStatus, MSFT_MpPreference |
core/wnf |
Create, update, query, and delete WNF state names |
core/native |
invoke – a general FFI, with typed buffers and DLL/file/registry/kernel/SCM handles |
core/power |
Battery, power schemes, thermal and fan sensors, EMI, processor info |
core/net |
http_req |
core/sym |
Symbol download, sym_decode_stack |
core/ai |
A local language model – Summarize, Rewrite, TextToTable |
core/utc, core/oset, core/cabinet |
DiagTrack scenario state, OneSettings config, .cab creation |
The remaining nineteen do not belong to any library. core/global.lua injects them directly into the environment of every script, so they are available without importing anything: getpid, getcmd, getcwd, process_info, system_info, system_times, sessionid, thread_name, create_guid, sleep, event_write, and a handful of timing primitives.
Registry write, arbitrary file I/O, process creation, WMI method invocation, and a general-purpose FFI. That is roughly the capability set of a systems administration language. It is quite a lot for a program whose job is to notice that your laptop is running hot.
The file access surprised me the most, because it has no restriction whatsoever. This is the whole of write_data, at line 9 of core/file.lua:
function write_data(path, data)
assert(io.open(path, "wb")):write(data)
end
No prefix check, no allowlist, no canonicalization. And before you assume io here is some hardened Microsoft replacement, it is not – windiag.pdb exports luaopen_io, luaopen_os, and luaopen_package unmodified, so this is the stock PUC-Rio liolib calling fopen.
This is the part the viral post got backwards, so let me be clear. None of the shipped scripts do anything questionable with this. They read sensors and write JSON into their own directories, which is exactly what a diagnostics service should do. The engine underneath would simply let them do much more, and what keeps them in their lane is that Microsoft wrote them that way.
It is worth seeing what the sharper capabilities are actually used for, because the answer is mundane in every case.
File I/O. scenario/system_summary writes its result to a temporary file, and misc/artifact_manager later publishes it into %ProgramData%\Whesvc\ and enforces the retention limits:
local path = env.expand("%TEMP%\\") .. name .. "_summary.json"
file.write_data(path, json.stringify(summary))
Registry. scenario/perftrack_monitor keeps its counters under whesvc’s own key so they survive a restart. On my machine one of those entries looks like this:
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\whesvc\scenarios\29783662:1
id a92c2390-587f-4ee7-af5b-d1e05402e98a
number 29783662
on_ac 1
total_count 5
bucket_ms 2
last_update_time 134309573839635035
The only write that leaves whesvc’s own keys is misc/driver_info, which can set VerifyDrivers, VerifyDriverLevel, VerifyMode, and VerifierOptions under Session Manager\Memory Management. That is Driver Verifier configuration, used by memory_monitor for pool tracking, and it is gated behind WINDIAG_DRIVER_TELEMETRY_ENABLED. It is easily the most invasive thing any of these scripts can do to a machine.
Process creation. Exactly one script spawns a process through security.create_process, and it runs an in-box Windows tool:
local opts = {
cmd_line = sysdir .. "/powercfg.exe /sleepstudy /output \"" .. outfile .. "\"",
redirected = true,
console = false,
}
local proc = security.create_process(opts)
That is misc/sleep_study asking powercfg for a sleep study report and then reading its output. Interestingly, there is a second process-spawning path that does not use this binding at all: core/etw shells out to wpr.exe -merge through io_popen. Two mechanisms for the same job, in the same codebase.
Getting the Scripts Out
Now I can go read them. Except the resources are not plain Lua, and this is probably why nobody had looked inside: running strings on whesvc_assets.dll shows nothing resembling a script.
Each resource starts with a small header:
0x00 u32 magic 0xC0E5510A
0x04 u16 header size (0x18)
0x06 u16 tag
0x08 u64 uncompressed size
0x10 u64 uncompressed size (again)
0x18 u32 compressed size
0x1C ... 'CK' + raw DEFLATE
That CK is the giveaway: MSZIP, which is deflate with a two-byte signature in front, so the unpacker is four lines of Python. No encryption, and no hash or MAC anywhere in the header either, which will matter later.
Underneath the compression is Lua 5.4 bytecode. And here is the part I did not expect:
| Modules carrying debug info | 105 / 105 |
| Named local variables | 6,069 |
| Named upvalues | 2,149 |
| Line-number entries | 47,688 |
| Distinct source paths | 86 (@lualib\core\net.lua, @luamod\scenario\ecp.lua, …) |
Nothing was stripped. The header sentinels are the stock values (LUAC_INT = 0x5678, LUAC_NUM = 370.5), and the opcode table is unmodified upstream ordering. I wrote a disassembler against the plain Lua 5.4 opcode list and it decoded every module correctly on the first attempt.
Why keep the debug info? I suspect it is deliberate. The engine formats its errors as file(line)!address, and the WIL telemetry logs failures with call context. If you strip the symbols, you blind yourself to your own field failures. There are even lua_debugger_enabled and vscode_debugger_enabled hooks sitting in the sandbox script.
So What Is Keeping This In Check?
Two things are supposed to, and both are worth a look.
The first is a sandbox. It lives in a LUASB resource plus core/global.lua, and before any scenario script runs it deletes a list of globals from the environment: debug, require, os, package, loadfile, dofile, load, getmetatable, setmetatable, and collectgarbage.
That accomplishes one thing, and it is the important one. With load, loadfile, dofile, and require all gone, a scenario script cannot load code that did not ship in the signed DLL. There is a path that loads Lua from the filesystem through a WINDIAG_LUALIB_PATH environment variable, but it is gated on the host process being windiag.exe, a separate developer tool, so that door is closed too.
Beyond that it does not do much. io was never on the block list, and io.popen is deliberately re-exported as io_popen – core/etw.lua uses it to shell out to wpr.exe -merge – so removing os does not remove the ability to run commands. The core libraries also grab what they need before the strip happens, and core/global keeps an upvalue named, with admirable honesty, sandbox_stripped_refs. It is namespace hygiene rather than confinement.
The second thing is the signature on whesvc_assets.dll, since that is the DLL holding the code that actually runs. Except there is no check. Neither DLL imports WinVerifyTrust, the process never sets MicrosoftSignedOnly, and there is no hash or MAC in the container header either. The file is validly signed, and nothing looks at that signature when it is loaded.
But that does not really matter, because of who is allowed to write the file:
C:\Windows\System32\whesvc_assets.dll
NT SERVICE\TrustedInstaller:(F)
BUILTIN\Administrators:(RX)
NT AUTHORITY\SYSTEM:(RX)
BUILTIN\Users:(RX)
TrustedInstaller has full control, and everybody else – including SYSTEM and Administrators – gets read and execute only. Anyone who can replace this file is already an administrator, and administrator to SYSTEM is not a boundary Windows defends anyway. So this is fine.
So what actually keeps this engine in bounds is not the sandbox and not the signature. It is a file permission, plus the set of scripts Microsoft chose to ship. That is enough in practice, but it is worth knowing that is what the safety rests on.
So What Do the Scripts Actually Do?
Now I can finally answer the question I left hanging earlier: what is builtin://scenario/init?
The builtin:// scheme is handled inside windiag.dll, and it simply names a resource. scenario/init becomes the LUAMOD resource called SCENARIO/INIT, which is 536 bytes on disk and unpacks to 777 bytes of bytecode. The debug info calls it @luamod\scenario\init.lua, and it is 48 instructions in total, so I can show the whole thing:
disable_global_variables()
local opts = module_options{
disabled_modules = "",
}
local workflow = reflib("misc/module_workflow")
local telemetry = reflib("misc/utc_telemetry")
telemetry.event_write("FeatureStates", feature_states())
workflow.start_feature_modules({
{ module_uri = "builtin://scenario/housekeeper" },
{ module_uri = "builtin://scenario/rtsmon" },
{ module_uri = "builtin://scenario/devicehot" },
{ feature_name = "ECP",
module_uri = "builtin://scenario/ecp",
on_battery_only = true },
{ feature_name = "MemoryLeakDetection",
module_uri = "builtin://scenario/memory_handle_leak" },
{ module_uri = "builtin://scenario/noisy_fan" },
}, "init", opts.disabled_modules)
That is the entire entry point. It locks down the environment, pulls in two helper libraries, emits one telemetry event recording which feature flags are on, and hands six modules to the workflow starter. No logic of its own beyond that.
From there the structure is easy to follow. Of the 23 scenario modules, 21 start by default. Those six above are the first wave, and one of them, rtsmon, opens a realtime ETW session and starts fourteen more. Thirteen run unconditionally, six sit behind Windows feature flags that Microsoft controls server-side, and one needs a battery to be present.
Roughly, they fall into four groups.
Responsiveness. hang_trace waits on WerHangTraceSignal and captures a trace when an application stops responding. input_delays and output_delays watch input latency and display glitches. slow_app_launch fires when an app takes too long to start. svc_start_stop flags services stuck in a pending state. And hotkey_trace is the interesting one, because it captures a full trace when you press a hotkey. That is the manual reproduction path, for when you can trigger the problem yourself.
Power and thermals. ecp decides between LowerPower, NoChange, and HigherPower, and then applies the result. excessive_power_drain looks for sustained drain and badly behaved background apps. fast_battery_drain_improvement is a small state machine that engages QoS separation and CPU frequency throttling when drain stays high. sleep_offenders works out what kept the machine awake, and shells out to powercfg /sleepstudy to do it.
Memory. memory_monitor takes heap snapshots. memory_handle_leak does real leak detection: it tracks per-process memory and handle counts across intervals, and uses consecutive-increase counts together with a linear regression slope to tell a leak apart from normal growth. That is more statistics than I expected to find in a shipped diagnostic script.
Aggregation. system_summary is where the famous 15 minutes comes from. It reads WINDIAG_SYSTEM_SUMMARY_FLUSH_SEC, which defaults to 900. The artifacts on my own machine agree:
{"start_time":"2026-08-11 21:02:16.727114",
"end_time":"2026-08-11 21:17:56.930144",
"elapsed_time":"00:15:40.203030", ...}
So the 15 minutes is a real number. The question is what happens when the timer fires, and the answer is that it writes a JSON file into C:\ProgramData\Whesvc\ and emits one small ETW telemetry event. Here is an entire crash record, from my machine:
{"app_name":"binaryninja.exe","mod_name":"TTDReplay.dll",
"exception_code":"0xC0000005","exception_offset":"0x994EA",
"app_version":"5.4.10219.0","count":1}
That is the payload everybody was worried about. It is a crash summary, and in this case it is my own debugger crashing on me during development.
The network side is similarly undramatic. Across all 84 scripts the only URL is symweb.azurefd.net, which is Microsoft’s public symbol server, and it is gated behind a WINDIAG_SYM_CLOUD_TOKEN environment variable that is not set on a retail machine. No scenario module makes an HTTP request at all. windiag.dll imports three WinINet functions and zero socket functions. The running service holds no TCP or UDP endpoints.
Two controls deserve more credit than the discussion gave Microsoft. Heavy trace capture checks AllowTelemetry and requires it to be 3, meaning full or optional diagnostic data, and returns false when the value cannot be read at all – so it fails closed. And auto-escalation, the one mechanism that can hand data to the telemetry pipeline without you filing feedback, is off by default in all ten automatic scenarios. It is on in exactly one: hotkey_trace, the one you trigger yourself.
How Does It Know the Fan Is Loud?
SCENARIO/NOISY_FAN was actually the first module I opened, because I could not immediately think of a non-alarming way to detect a noisy fan. I have taken a fan apart on this blog before, so I felt some obligation to check this one.
Happily, it is not listening to anything. I went through the full import tables of both binaries, and there are no audio APIs anywhere.
What it does instead is read RPM. The platform’s thermal stack exposes a fan descriptor that sorts RPM into four zones, each with a maximum RPM and an accumulated time spent in that zone:
FanNoiseZoneLow FanNoiseZoneMedium FanNoiseZoneMediumHigh FanNoiseZoneHigh
The module polls on an interval, and then does exactly one comparison:
if time_per_impact[FanNoiseZoneHigh].seconds_in_zone > high_impact_sec_threshold then
device_health.signal_problem{ problem_area = Power, source = "NoisyFan" }
end
So “noisy” means the fan has spent more than N accumulated seconds above an RPM threshold that the OEM declared. It is a proxy for noise rather than a measurement of it, which is a sensible way to do this without a microphone. And if your machine has no fan telemetry at all, the module exits immediately with Fan sensors not supported on device, which is what happens on most desktops.
There Was Already a CVE
While writing this up, I found that this service has already produced a real vulnerability: CVE-2025-59241, a local elevation of privilege with CVSS 7.8, fixed in builds 26100.6899 and 26200.6899 back in October 2025. Microsoft classified it as improper link resolution before file access. (Tenable has a more readable summary than the MSRC page.)
Which rather punctures my earlier line about nobody having opened this binary. Somebody clearly had, and they walked away with a CVE while I walked away with a blog post. I will console myself with the belief that I am at least the first to write the thing up in this much detail.
Microsoft published one sentence about it, and I could not find a pre-patch binary on my machine to diff against, so what follows is the shape of the problem rather than a confirmed account of the exact reported case.
The ingredients are still visible. The service creates C:\ProgramData\Whesvc\<artifact_type>\ if it does not exist and writes a file inside it, as SYSTEM, with no path validation. The type names are hardcoded and predictable, and system_summary guarantees a write every fifteen minutes. Meanwhile, look at who can write into that tree:
icacls C:\ProgramData\Whesvc
BUILTIN\Users:(I)(CI)(WD,AD,WEA,WA) <- create files and directories
CREATOR OWNER:(I)(OI)(CI)(IO)(F)
Every entry is marked (I), meaning inherited – the service never sets its own DACL, it just accepts whatever C:\ProgramData hands down. Once an unprivileged user can create a directory a SYSTEM process will later write into, the classic move is to make it a junction pointing somewhere else. The retention logic, which deletes old artifacts, offers the same trick in reverse.
The fix is visible in the current binaries, and I like it. Remember that SetProcessMitigationPolicy(ProcessRedirectionTrustPolicy, 1) at the very top of StartWHEService? That tells the kernel to refuse to follow junctions and symlinks created by less-trusted principals. It kills the entire class of bug rather than patching one path, and it is set declaratively in the svchost group configuration as well. There is a second and narrower guard in core/file.lua, where the recursive directory walker refuses to descend into reparse points:
if file.is_directory(entry) and not file.is_reparse_point(entry) then
table.insert(q, entry.path)
end
This is the only call to is_reparse_point in all 84 modules, which tells you plainly what it was added for.
The artifact directory still inherits its permissive DACL rather than setting its own, so the defense rests on that one kernel mitigation. It is a good mitigation and it does the job, but an explicit DACL would have made it two layers instead of one.
Final Thoughts
The viral claim was wrong on essentially every count. The service is not new, it does not upload your traces, and it is not recording anything. The 15 minutes is a real number attached to a thoroughly boring event, and Hanselman’s description matches the code right down to the directory path he quoted.
What is worth talking about is the machinery rather than the behavior. The scripts read sensors and write JSON files, while the engine underneath would let them write any file, set any registry key, spawn processes, and call arbitrary native functions through an FFI, all as SYSTEM. None of that is being misused – I went looking for something dodgy and did not find it. It is simply more capability than the task calls for, and CVE-2025-59241 is a fair illustration of what that can cost. Windows Health and Optimized Experiences is not spyware; it is a legitimate diagnostics service built on a general-purpose scripting runtime, and that is a different thing to threat model than a program which reads a fan sensor.
The tooling I wrote for this – the PE resource extractor, the MSZIP container unpacker, and the Lua 5.4 disassembler – is on GitHub, together with instructions for unpacking the scripts from your own copy of Windows.