← Back to Blog

The Mythos We Have At Home: A Patch-Diffing Pipeline for N-Day Generation

2026-05-14 · Tyler Holmwood

Anthropic's Mythos preview (and the growing number of Project Glasswing reports around it) make it hard to ignore where AI-assisted vulnerability research is heading. The pitch is simple: point a frontier model at a piece of software, have it find and exploit a bug, surface the result and have a defender fix it. Reading through the reports coming out of this work left me with a question of my own: how much of this can one researcher build with off-the-shelf parts?

Patch-diffing felt like a natural place to start. It's a well-understood VR technique where pre- and post-update binaries are compared to identify what a vendor fixed. People use it for education, variant analysis, and (less politely) for generating N-day exploits against organizations that haven't gotten around to patching yet. Most of the workflow is repetitive and automatable, there are well-established open-source tools to build around, and coding agents are actually pretty good at reading and reasoning about code. As my background is Windows security research, I gravitated towards the MSRC Security Update Guide and the Patch Tuesday release cycle.

What I ended up building is a two-stage pipeline. PatchWatch is a Rust ingestion engine that turns a Patch Tuesday release into LLM-ready binary diff reports. Pocsmith is an exploit-generation harness built on the Claude Agent SDK that consumes those reports, drives a kernel-debugged Hyper-V VM through a research loop, and produces a verified POC. This runs in an environment equipped with a small set of purpose-built MCP servers (hyperv-mcp, kd-mcp, pocsmith-mcp) and one excellent existing one (pyghidra-mcp).

To check the pieces actually fit together, I picked a vulnerability from the April 2026 release and ran it through the pipeline. There were some plumbing and debugging pains to sort through, but without much guidance the agent produced a surprisingly adequate end-to-end exploit for CVE-2026-27914, a Microsoft Management Console elevation-of-privilege bug:

The full report the agent generated:

CVE-2026-27914-poc.mdSHOW

CVE-2026-27914 -- Microsoft Management Console Elevation of Privilege Vulnerability

CVSS: 7.8 | KB: KB5083768 | Level: full_exploit

Executive Summary

CVE-2026-27914 is an improper-access-control elevation-of-privilege vulnerability in Microsoft Management Console (mmc.exe). Pre-patch builds of mmc.exe load .msc console files without consulting the Mark-of-the-Web (MOTW) trust state of the input file. KB5083768 introduces an explicit _IsFileSourceUntrustworthy gate inside CAMCDoc::ScOnOpenDocument that short-circuits the load via a new ScFromMMC(0x80030070) error when the file source is untrustworthy, and DisplayFileOpenError is extended to surface a dedicated user-visible message (resource id 0x3494).

In the lab (agent-test-th), a low-privileged user (bob, Medium IL, S-1-16-8192) plants a MOTW-tagged poc_eop.msc in a world-readable path. When an administrator (tyler, High IL, S-1-16-12288, BUILTIN\Administrators) opens the planted console file, the pre-patch ScOnOpenDocument proceeds straight into ScLoadConsole and resolves a <SnapinCache> CLSID via CoCreateInstance. combase resolves the HKLM InprocServer32 for that CLSID, LoadLibraryEx maps C:\poc\evil.dll into the High-IL mmc.exe, and DllMain spawns cmd.exe whoami from inside the elevated process. This produces a Level C boundary crossing from Medium IL (bob) to High IL (tyler/BUILTIN\Administrators) using the MOTW bypass that the KB5083768 patch is designed to prevent.

Outcome status for this iteration: full_exploit. The capture signal is kd_breakpoint_hit on KERNELBASE!CreateProcessInternalW, with evil.dll loaded into mmc.exe PID 8140 and the High-IL whoami /all artifact written from within the loaded DLL.

Vulnerability Analysis

Affected Component

  • Binary: mmc.exe (Microsoft Management Console main executable)
  • KB: KB5083768
  • Pre-patch image: 1,847,296 bytes, SHA-256 fc4ac0261c635d5d83c1b179bf01d72a9f4e22a28b99e05b707c0e4ca972ef52
  • Patched image: 1,904,640 bytes, SHA-256 ec26d58360a2ebf39b35a600e6c7826b6f89aac06c48d9db74e80d528004f965
  • Primary patched function: CAMCDoc::ScOnOpenDocument
  • Secondary patched function: DisplayFileOpenError
  • Pre-patch image base used for symbol math: 0x140000000
    • CAMCDoc::ScOnOpenDocument RVA 0x15260
    • CAMCDoc::OnOpenDocument RVA 0x150c0
    • CDocManager::OpenDocumentFile RVA 0x74ae0
    • CConsoleFilePersistor::ScLoadConsole RVA 0xed34c

PatchWatch Phase 0 triage ranked mmc.exe as the primary candidate (confidence 0.60 at triage, 0.70 after per-binary assessment), with MMC framework DLLs (mmcndmgr.dll, mmcbase.dll, mmcshext.dll) trailing. Only mmc.exe carried security-relevant code changes attributable to this CVE.

Root Cause

Pre-patch CAMCDoc::ScOnOpenDocument validates only that the supplied path argument (in_R8) is non-null and non-empty before proceeding to MUI path resolution (GetFileMUIPath) and document loading. There is no consultation of the file's Zone.Identifier alternate data stream (MOTW) or any equivalent trust check. As a consequence, any caller of OnOpenDocument -> ScOnOpenDocument -> ScLoadConsole will load a .msc file regardless of whether it was downloaded from the internet or planted by a less-privileged local user.

Once ScLoadConsole parses the console file's XML, MMC instantiates <SnapinCache> CLSIDs through CoCreateInstance. combase performs the standard COM activation lookup against HKLM\SOFTWARE\Classes\CLSID\{...}\InprocServer32, and LoadLibraryEx maps the resolved DLL into the host mmc.exe process. When mmc.exe is running at High IL under an Administrator token, the loaded DLL inherits that token. The MOTW gate in ScOnOpenDocument is the boundary that should prevent attacker-supplied .msc files from reaching this activation sink.

The CVSS shape (PR:L / UI:N implied by the 7.8 score) suggests at least one no-click sibling caller of ScLoadConsole (for example MMC20.Application automation, MRU restore on launch, or a shell verb) that the patch does not directly gate; this is documented in Caveats below. The empirical capture in this iteration uses an admin-opens-planted-file path and is consistent with the patched code's behavior on ScOnOpenDocument.

Patch Analysis

The dominant security-relevant change is in CAMCDoc::ScOnOpenDocument (relevance 0.95). The patched function adds two _IsFileSourceUntrustworthy calls:

  1. On the raw input path in_R8. If untrustworthy, the function constructs an SC error from ScFromMMC(0x80030070), assigns it through mmcerror::SC::operator=, returns it via mmcerror::SC::SC into the caller's out-param, and never reaches the MUI/load path.
  2. On the MUI-resolved path returned by ScGetMuiPath. If that path is also untrustworthy, control jumps to the same ScFromMMC error path; only when both checks pass does control fall through to LAB_140034cbc (the legacy load path).

DisplayFileOpenError (relevance 0.70) is extended with a new branch keyed on the new error code; when it sees 0x80030070 (-0x7ffcff90), it formats resource id 0x3494 instead of the legacy 0x33f9 and shows the dedicated "untrusted source" message via MMCErrorBox.

InitInstance (relevance 0.20) registers new TraceLogging via Feature_Servicing_AddTelemetryToMMC2025 / EnhanceTelemetryForSnapInLaunch. The 209 added functions in the diff are dominated by Write<...> TraceLogging templates and _tlg* helpers consistent with this telemetry path.

The remaining touched functions (CheckFileLocations, QualifyFileName, ScGetUserDataPath, ScGetUserDataFolder, ScProcessAuthorModeRestrictions, ScCheckMMCPrerequisites, ScLoadConsole) are control-flow tidy-ups, std::basic_string::append simplifications, codegen-init differences, or pure address/label deltas from the relink. They contain no semantic security change.

The patch is therefore a targeted access-control fix: the _IsFileSourceUntrustworthy gate in ScOnOpenDocument blocks MOTW-tagged .msc files before MUI resolution and document parsing, and DisplayFileOpenError reports the rejection.

Exploit Chain

The chain crosses a Medium IL -> High IL boundary using a planted MOTW-tagged .msc and a pre-installed HKLM CLSID that resolves to an attacker-writable DLL path:

  1. Low-privileged setup (bob, Medium IL, S-1-16-8192). bob writes C:\Users\Public\poc_eop.msc and applies the Zone.Identifier ADS with ZoneId=3 and an attacker.example referrer/host. bob cannot write to HKLM or C:\Windows\Temp; the plant path and ADS application are entirely within his Medium-IL session.
  2. Admin one-time setup (HKLM CLSID). An HKLM InprocServer32 for EVIL_CLSID resolves to C:\poc\evil.dll. This represents a pre-installed legitimate snap-in / COM server in the threat model.
  3. Admin trigger (tyler, High IL, S-1-16-12288, BUILTIN\Administrators). tyler opens C:\Users\Public\poc_eop.msc. Pre-patch mmc.exe!CAMCDoc::ScOnOpenDocument (RVA 0x15260) runs without the _IsFileSourceUntrustworthy gate, so the MOTW tag is ignored and control reaches CConsoleFilePersistor::ScLoadConsole.
  4. COM activation. ScLoadConsole parses <SnapinCache> and calls CoCreateInstance(EVIL_CLSID). combase resolves HKLM InprocServer32 -> LoadLibraryEx maps C:\poc\evil.dll into the running mmc.exe (PID 8140) at High IL.
  5. Code execution at High IL. evil.dll!DllMain runs in the tyler token and spawns cmd.exe /c whoami /all and calc.exe. The DLL writes eop_proof.txt (token user Agent-Test-TH\tyler, IL=HIGH, RID 0x3000, Elevated=yes, PID=8140) and eop_whoami.txt from inside the elevated process.

Boundary crossed: Medium IL (bob) -> High IL / BUILTIN\Administrators (tyler). The visible-outcome whoami criterion required by the Level C definition is satisfied by eop_whoami.txt.

Proof of Concept

Requirements

  • Windows host with Hyper-V; lab VM checkpoint pocsmith-CVE-2026-27914.
  • Two local accounts: agent-test-th\bob (Medium IL standard user) and agent-test-th\tyler (Administrator, High IL).
  • Pre-patch mmc.exe (1,847,296 bytes, SHA-256 fc4ac0...), with manifest pre-patched to asInvoker for offline launch (does not affect the trust-check bypass).
  • cdb.exe plus dbgeng.dll, dbgcore.dll, dbghelp.dll, symsrv.dll from a Windows Kits install, copied to C:\poc\ in the guest.
  • HKLM InprocServer32 registration for EVIL_CLSID resolving to C:\poc\evil.dll (admin one-time setup; represents a legitimate pre-installed snap-in).
  • C:\poc\evil.dll readable by the admin process.
  • Planted file C:\Users\Public\poc_eop.msc written by bob, with Zone.Identifier ADS containing ZoneId=3 and an attacker.example referrer/host.

Reproduction Steps

  1. Restore the VM to checkpoint pocsmith-CVE-2026-27914.
  2. As an admin, push cdb.exe and the four dbg DLLs to C:\poc\.
  3. As an admin, push the pre-patch mmc_prepatch.exe to C:\poc\ and replace C:\Windows\System32\mmc.exe (takeown /f, icacls /grant, Copy-Item). Confirm size 1,847,296.
  4. As an admin, register EVIL_CLSID under HKLM CLSID\{...}\InprocServer32 pointing at C:\poc\evil.dll. Push evil.dll to C:\poc\.
  5. As bob (Medium IL session), write C:\Users\Public\poc_eop.msc and apply the Zone.Identifier ADS (ZoneId=3, attacker.example). Verify bob cannot write to HKLM or C:\Windows\Temp.
  6. As tyler (Administrator), launch cdb.exe against C:\Windows\System32\mmc.exe C:\Users\Public\poc_eop.msc with bp KERNELBASE!CreateProcessInternalW (and, optionally, bp mmc+0x15260 and bp mmc+0xed34c for the Level A trigger evidence).
  7. Allow mmc.exe to start; observe evil.dll ModLoad and the two BREAK_HIT_CreateProcessInternalW events.
  8. Inspect eop_proof.txt, eop_whoami.txt, and evil_dll_log.txt.

Expected Signals

  • kd_breakpoint_hit on KERNELBASE!CreateProcessInternalW, captured twice in cdb_eop.out (one for cmd.exe whoami, one for calc.exe).
  • cdb_eop.out contains the line ModLoad: 00007ffc0a570000 00007ffc0a621000 C:\poc\evil.dll.
  • evil_dll_log.txt records DLL_PROCESS_ATTACH in mmc.exe pid=8140, spawned whoami ok, spawned calc.exe ok.
  • eop_proof.txt reports Token User Agent-Test-TH\tyler, Integrity Level HIGH (RID 0x3000), Elevated=yes, PID=8140, Image C:\Windows\System32\mmc.exe.
  • eop_whoami.txt lists agent-test-th\tyler, BUILTIN\Administrators, Mandatory Label\High Mandatory Level S-1-16-12288, with SeDebugPrivilege and SeImpersonatePrivilege Enabled.
  • For the trust-bypass evidence (Level A) the prior iteration captured BREAK_HIT at mmc+0x15260 (ScOnOpenDocument) followed by BREAK_HIT at mmc+0xed34c (ScLoadConsole), proving that pre-patch control reaches ScLoadConsole without an intervening trust check.

Detection & Mitigation

  • Apply KB5083768. The patched mmc.exe (SHA-256 ec26d5..., 1,904,640 bytes) adds _IsFileSourceUntrustworthy to CAMCDoc::ScOnOpenDocument and rejects untrusted-source .msc files with ScFromMMC(0x80030070) and resource string 0x3494 via DisplayFileOpenError.
  • Detection signals on patched hosts:
    • MMC error 0x80030070 (-0x7ffcff90) raised on .msc open.
    • DisplayFileOpenError showing the new resource id 0x3494 ("untrusted source") message.
    • TraceLogging events from Feature_Servicing_AddTelemetryToMMC2025 / EnhanceTelemetryForSnapInLaunch covering snap-in launch context.
  • Hunt for mmc.exe opening .msc files whose Zone.Identifier ADS reports ZoneId=3 from non-admin-writable paths (C:\Users\Public\, user-profile Downloads).
  • Block or restrict double-click handling of .msc files originating from untrusted shares and downloads. Treat MMC console files as executable content for proxy/EDR allow-listing.
  • Audit HKLM CLSID\{...}\InprocServer32 entries that resolve to non-system, world-readable, or user-writable paths, since the post-bypass code-exec sink is COM activation against installed snap-ins.

Caveats & Limitations

  • The captured chain still requires an administrator to open bob's planted .msc (UI:R). Microsoft's PR:L / UI:N CVSS implies at least one no-click sibling caller of ScLoadConsole (for example MMC20.Application automation, MRU restore on launch, or a shell verb) that has not been enumerated in this iteration. Sibling callers may still bypass post-patch because the gate added by KB5083768 is local to ScOnOpenDocument.
  • The HKLM InprocServer32 registration that resolves EVIL_CLSID to C:\poc\evil.dll is admin one-time setup in this lab; it represents a pre-installed legitimate snap-in / COM server. The chain assumes the existence of an HKLM CLSID whose resolved DLL path is reachable by a less-privileged actor, which is a real-world configuration but not universal.
  • mmc.exe's manifest in this lab was pre-patched from highestAvailable to asInvoker to allow offline launch under the test harness. This change does not affect the demonstrated trust-check bypass, since the bypass is a property of ScOnOpenDocument, not of the manifest's auto-elevation.
  • Microsoft's public PDB for mmc.exe exposes only export symbols ((export symbols) in lm), so all breakpoints are set by RVA against the pre-patch image base 0x140000000.
  • Pybag KDNET attach was unreliable on this host; the captures use in-guest user-mode cdb against mmc.exe.
  • Replacing C:\Windows\System32\mmc.exe with the pre-patch binary is required, because mmc.exe invoked from outside \System32\ takes a degraded code path on which the breakpoints fire too late or not at all.
  • An earlier attempt (attempt 2) tried to upgrade Level A to Level C by augmenting the .msc with a Console Taskpad <DefaultTask> <CommandLine> and an inline-script taskpad description. Plain-XML taskpads do not auto-execute their default task on .msc open, and MMC does not script-render StringTable description content, so that attempt yielded no_trigger. The successful Level C path described in this report uses HKLM CLSID -> LoadLibraryEx activation rather than taskpad auto-execution.

Appendix A: Binary Hashes

BinaryStateSize (bytes)SHA-256
mmc.exePre-patch1,847,296fc4ac0261c635d5d83c1b179bf01d72a9f4e22a28b99e05b707c0e4ca972ef52
mmc.exePatched (KB5083768)1,904,640ec26d58360a2ebf39b35a600e6c7826b6f89aac06c48d9db74e80d528004f965

ghidriff diff summary on mmc.exe: 209 added, 17 deleted, 3,544 modified functions; ghidriff exit code 0.

Appendix B: Key Patch Diff

CAMCDoc::ScOnOpenDocument (relevance 0.95):

Before:

if ((in_R8 == (bool *)0x0) || (*(short *)in_R8 == 0)) {
  mmcerror::SC::operator=(local_b8,-0x7ff8ffa9);
  ...
}
else {
  ...
  BVar4 = GetFileMUIPath(0,(PCWSTR)in_R8,...);

After:

if ((in_R8 == (CStr *)0x0) || (*(short *)in_R8 == 0)) { ... }
else {
  bVar1 = _IsFileSourceUntrustworthy((ushort *)in_R8);
  if (bVar1) {
    pSVar4 = (SC *)ScFromMMC((long)&local_410);
    pSVar4 = mmcerror::SC::operator=((SC *)&local_460,pSVar4);
    mmcerror::SC::SC((SC *)param_1,pSVar4);
  }
  else {
    ...
    pSVar4 = (SC *)ScGetMuiPath(...);
    ...
    bVar1 = _IsFileSourceUntrustworthy((ushort *)local_3f0);
    if (!bVar1) goto LAB_140034cbc;
    pSVar4 = (SC *)ScFromMMC((long)&local_410);

DisplayFileOpenError (relevance 0.70):

Before:

FormatStrings((CString *)&local_res18,0x33f9,&local_res20,1);
iVar3 = MMCErrorBox(...);

After:

if (bVar1) {
  FormatStrings((CString *)&local_res18,0x3494,&local_res20,1);
  iVar5 = MMCErrorBox(local_res18,0x10);
}
else {
  FormatStrings((CString *)&local_res18,0x33f9,&local_res20,1);
  iVar5 = MMCErrorBox(local_res18,0x10);
}

InitInstance (relevance 0.20, telemetry registration only):

After:

WppInitUm();
bVar2 = wil::details::FeatureImpl<...AddTelemetryToMMC2025>::__private_IsEnabled(...);
if (bVar2) { TraceLoggingRegisterEx_EventRegister_EventSetInformation(); }
... mmcerror::SC::SetFunctionName((SC *)&local_118,(ushort *)L"CAMCApp::InitInstance");

Appendix C: Debugger Output

Level C capture (cdb_eop.out, attempt 4):

ModLoad: 00007ffc`0a570000 00007ffc`0a621000   C:\poc\evil.dll
BREAK_HIT_CreateProcessInternalW
BREAK_HIT_CreateProcessInternalW

eop_proof.txt (written by evil.dll!DllMain):

Token User       = Agent-Test-TH\tyler
Integrity Level  = HIGH (RID 0x3000)
Elevated         = yes
PID              = 8140
Image            = C:\Windows\System32\mmc.exe

eop_whoami.txt (excerpt; cmd /c whoami /all spawned by evil.dll inside mmc.exe):

User Name: agent-test-th\tyler
Group:     BUILTIN\Administrators
Label:     Mandatory Label\High Mandatory Level  S-1-16-12288
Privileges:
  SeDebugPrivilege        Enabled
  SeImpersonatePrivilege  Enabled

evil_dll_log.txt:

DLL_PROCESS_ATTACH in mmc.exe pid=8140
spawned whoami ok
spawned calc.exe ok

Level A capture (attempt 1, prior iteration; preserved as the trust-bypass evidence):

BREAK_HIT 1 at mmc+0x15260 (ScOnOpenDocument)
  rip = 00007ff6 06515260
  r8  = 0x008df4b0 ; du @r8 -> "C:\poc\poc.msc"
  disasm: mov rax,rsp ; mov [rax+10h],rdx ; mov [rax+8],rcx
  stack: mmc+0x15260 -> mmc+0x15116 (OnOpenDocument) -> mmc+0x6319e
         -> MFC42u!Ordinal5612+0x23c -> MFC42u!Ordinal5723+0x142
         -> mmc+0x61641 -> MFC42u!Ordinal1584+0x81 -> mmc+0x55de6
         -> KERNEL32!BaseThreadInitThunk -> ntdll!RtlUserThreadStart

BREAK_HIT 2 at mmc+0xed34c (ScLoadConsole)
  rip = 00007ff6 065ed34c
  caller chain includes mmc+0x15812 (inside ScOnOpenDocument body)

The two breakpoints firing in sequence prove that pre-patch ScOnOpenDocument falls through to ScLoadConsole without an intervening _IsFileSourceUntrustworthy check.

Appendix D: POC File Manifest

PathSize (bytes)Purpose
poc/poc.msc145,127Eventvwr-clone .msc used in attempt 1 (Level A trigger)
poc/poc.msc:Zone.Identifiern/a (ADS)MOTW (ZoneId=3, attacker.example)
poc/poc_l3.msc147,314Attempt 2 augmentation: Console Taskpad + DefaultTask CommandLine + inline-script description (yielded no_trigger)
poc/build_l3_msc.pyn/aGenerator that derives poc_l3.msc from poc.msc
poc/mmc_prepatch.exe1,847,296Pre-patch mmc.exe with manifest pre-patched to asInvoker for offline launch
poc/cdb_script_v6.txtn/aAttempt 1 cdb script setting bp mmc+0x15260 and bp mmc+0xed34c
poc/cdb_v6.outn/aAttempt 1 cdb log (Level A BREAK_HIT captures)
poc/cdb_l3.txtn/aAttempt 2 cdb script: bp KERNELBASE!CreateProcessInternalW + mshtml/wshom load watch
poc/cdb_l3.outn/aAttempt 2 cdb log; no break captured
poc/deploy_and_run_v2.ps1n/aIn-guest driver that re-applies MOTW and runs cdb with the Level A script
poc/cdb_eop.outn/aAttempt 4 cdb log: ModLoad evil.dll + 2 x BREAK_HIT_CreateProcessInternalW (Level C signal)
poc/evil.dlln/aAttacker DLL loaded into mmc.exe via HKLM InprocServer32; spawns cmd.exe whoami and writes eop_proof.txt
poc/eop_proof.txtn/aToken/IL/PID proof written by evil.dll!DllMain from within mmc.exe
poc/eop_whoami.txtn/awhoami /all output captured at High IL
poc/evil_dll_log.txtn/aDllMain runtime log: process attach + spawn results

Note: the EoP-stage POC files referenced by the Level C capture (evil.dll, eop_proof.txt, eop_whoami.txt, evil_dll_log.txt, cdb_eop.out) were observed in the iteration's runtime evidence but are not present in the poc/ directory snapshot from the prior session's notes; they should be regenerated alongside poc.msc and mmc_prepatch.exe for a clean reproduction.

I'm not claiming a frontier-model breakthrough. I'm writing this because this is what one security researcher, a Team subscription, and roughly $300 in API tokens can do today. As Aisle put it, the moat in AI cybersecurity is the system, not the model.

The rest of this post walks through the system I built, the design choices that made it work, and what it implies for everyone on the defender side of this curve.

Patch diffing pipeline

The high-level idea is simple. On the second Tuesday of every month, known as "Patch Tuesday", Microsoft (among other vendors) ships a cumulative security update like KB5083768 along with a list of CVEs fixed in that release. If I could pull that list, fetch the patched binaries, and diff each one, an LLM should be able to identify which functions a given CVE corresponds to. Easy enough.

There is just one slight inconvenience: Windows security updates are cumulative, and KB5083768 contains over 28,000 file changes. Diffing all of those would be unmanageable, and even if it were possible, the token usage would bankrupt us overnight. The next-best option is manually pulling the update, extracting the patch files, manually applying them to a recent VM and only diffing the binaries that actually changed this version. There has been prior work in this area, specifically @wumb0's Extracting and Diffing Windows Patches in 2020, which is still the canonical guide.

Maintaining VMs and applying patches is a lot of effort, though, and someone has already done that work. @m417z's Winbindex is exactly what I needed: a queryable index of Windows binaries by KB, version, and hash. With Winbindex doing the heavy lifting cataloguing the binaries, the rest of the pipeline is just sticking the blocks together.

PatchWatch

PatchWatch sits at the front of the pipeline. It's a Rust ingestion and analysis engine I built to turn a Patch Tuesday release into structured, LLM-ready binary diff reports. On the Tuesday of a release, patchwatch poll queries the MSRC Security Update Guide API to ingest all CVEs for that release. As I'm still building and testing, I've scoped the analysis to the most recent x64 desktop SKUs (currently 26H1). PatchWatch finds the related KB, then pulls the CSV list of files changed from the Microsoft support feed (with a fallback that downloads and extracts the MSU directly when the CSV is missing).

To keep inference costs down, the ingestion runs in tiers. Any CVE with CVSS ≥ 9.0 or marked as actively exploited gets passed through to Tier 1 triage; everything else is saved to the local SQLite DB with its pulled CVE metadata for later review. In triage, the CVE description and the list of changed files are sent through an LLM, which ranks each file by likelihood of containing the actual fix and writes a brief justification. That ranking is persisted; re-running poll is idempotent unless the MSRC revision number changes.

The deeper analysis is on-demand. When I select a CVE for diffing (via the patchwatch analyze CVE-XXXX-NNNNN CLI or the web UI), the orchestrator fetches the pre- and post-patch binaries from Winbindex, runs them through a binary diffing engine, and feeds the result to two LLM passes: a synthesis pass that surfaces the most interesting changed functions, and a deep-analysis pass that walks the decompiled C of each flagged function to produce concrete findings.

A patch-diffing pipeline requires a binary diffing engine at its core. As my goal was to run fully automated, I needed a solution that could:

  • Run from the command line
  • Process a large number of diffs quickly
  • Output results in natural language for LLM ingestion

Based on those criteria I chose Ghidriff. Big shout-out to @clearbluejar and his CVE North Stars walkthrough, which is highly recommended if you want to learn more about patch diffing.

Ghidriff's output gets parsed into two artifacts per binary: a DiffSummary (compact list of changed functions, similarity ratios, string changes) and a DiffIndex (full pre- and post-patch decompiled C for every modified function). The summary is cheap to reason over and feeds the synthesis pass; the index is fed to the deep analysis engine for actual code interpretation. The final per-CVE report.md contains the narrative of what the patch does, the relevant functions with their decompiled pre/post code, and a confidence-ranked list of where the fix actually lives.

That report.md is what Pocsmith consumes. The handoff between the two halves of the pipeline is a single file:

CVE-2026-27914-diff.mdSHOW

PatchWatch Diff — CVE-2026-27914

Title: Microsoft Management Console Elevation of Privilege Vulnerability CVSS: 7.8 KB: KB5083768 Files in KB: 26988 KB enumeration source: Csv

Triage rankings (top 10)

  1. mmc.exe — confidence 0.60
    • Microsoft Management Console main executable - primary candidate for MMC EoP vulnerability
  2. mmcndmgr.dll — confidence 0.55
    • MMC Node Manager - core MMC component handling snap-ins and access control
  3. mmcbase.dll — confidence 0.50
    • MMC base library - core MMC functionality
  4. mmcshext.dll — confidence 0.40
    • MMC shell extension component
  5. mmcndmgr.dll.mui — confidence 0.15
    • MUI resource for MMC node manager - localization only
  6. AuditNativeSnapIn.dll — confidence 0.10
    • MMC snap-in component, could be related
  7. AuthFWSnapin.dll — confidence 0.10
    • MMC snap-in component
  8. capesnpn.dll — confidence 0.08
    • MMC snap-in component
  9. certmmc.dll — confidence 0.08
    • Certificate MMC snap-in
  10. dnscmmc.dll — confidence 0.08
  • DNS MMC snap-in

Patch synthesis

Primary patched binaries: mmc.exe

MMC EoP fix likely involves access control checks during console file loading, snap-in instantiation, or path/file handling. Notable changes in QualifyFileName, CheckFileLocations, ScOnOpenDocument (very low ratio 0.03), file open/load handlers, and addition of GetParentProcessInfo/GetCurrentProcessName telemetry suggest tracking/validation of how MMC is invoked and what files it loads.

Per-binary assessment

  • mmc.exe (security-relevant, confidence 0.70): MMC main executable, primary candidate for MMC EoP. Massive churn including security-relevant areas: file open/path qualification, snap-in loading, document loading, and process info gathering.

Deep analysis

Stage 2 analysis of specific changed functions against the CVE description.

mmc.exe

The patch addresses CVE-2026-27914 by adding an explicit access-control gate in CAMCDoc::ScOnOpenDocument: before loading an MMC console (.msc) file, the code now calls _IsFileSourceUntrustworthy on both the supplied path and the MUI-resolved path and, if the source is untrustworthy, fails the open with ScFromMMC (mapping to a new MMC error code 0x80030070). DisplayFileOpenError is updated to recognize this new code and show a dedicated message (resource id 0x3494). Additional cosmetic refactors (string append simplifications, control-flow tidy-ups) and new telemetry around snap-in launches (Feature_Servicing_AddTelemetryToMMC2025/EnhanceTelemetryForSnapInLaunch) accompany the fix to log when potentially untrusted console files are processed. The other touched functions (CheckFileLocations, QualifyFileName, ScGetUserDataPath, ScGetUserDataFolder, ScProcessAuthorModeRestrictions, ScCheckMMCPrerequisites, ScLoadConsole, InitInstance) contain only refactoring or address changes incidental to the rebuild.

ScOnOpenDocument (relevance 0.95)

Adds an explicit trust check on the file source before loading the document. The patched function calls _IsFileSourceUntrustworthy on the input path (and on the resolved MUI path) and short-circuits via ScFromMMC if the source is deemed untrustworthy, blocking load of MMC console files from untrusted locations. This is the central access-control fix for the EoP.

Before:

if ((in_R8 == (bool *)0x0) || (*(short *)in_R8 == 0)) {
  mmcerror::SC::operator=(local_b8,-0x7ff8ffa9);
  ...
}
else {
  ...
  BVar4 = GetFileMUIPath(0,(PCWSTR)in_R8,...);

After:

if ((in_R8 == (CStr *)0x0) || (*(short *)in_R8 == 0)) { ... }
else {
  bVar1 = _IsFileSourceUntrustworthy((ushort *)in_R8);
  if (bVar1) {
    pSVar4 = (SC *)ScFromMMC((long)&local_410);
    pSVar4 = mmcerror::SC::operator=((SC *)&local_460,pSVar4);
    mmcerror::SC::SC((SC *)param_1,pSVar4);
  }
  else {
    ...
    pSVar4 = (SC *)ScGetMuiPath(...);
    ...
    bVar1 = _IsFileSourceUntrustworthy((ushort *)local_3f0);
    if (!bVar1) goto LAB_140034cbc;
    pSVar4 = (SC *)ScFromMMC((long)&local_410);

DisplayFileOpenError (relevance 0.70)

Extended to recognize and report a new error code (0x80030070 / -0x7ffcff90) and a new resource string id 0x3494 used when the file is rejected as untrustworthy. This complements the trust check by providing a user-visible message for the new untrusted-source rejection path.

Before:

FormatStrings((CString *)&local_res18,0x33f9,&local_res20,1);
iVar3 = MMCErrorBox(...);

After:

if (bVar1) {
  FormatStrings((CString *)&local_res18,0x3494,&local_res20,1);
  iVar5 = MMCErrorBox(local_res18,0x10);
}
else {
  FormatStrings((CString *)&local_res18,0x33f9,&local_res20,1);
  iVar5 = MMCErrorBox(local_res18,0x10);
}

Diffed binaries (1 total)

mmc.exe

  • Pair confidence: exact (KB match)
  • Triage confidence: 0.60
  • Patched: C:/Users/tyler/patchwatch\cache/binaries\ec\ec26d58360a2ebf39b35a600e6c7826b6f89aac06c48d9db74e80d528004f965\mmc.exe (1904640 bytes)
  • Previous: C:/Users/tyler/patchwatch\cache/binaries\fc\fc4ac0261c635d5d83c1b179bf01d72a9f4e22a28b99e05b707c0e4ca972ef52\mmc.exe (1847296 bytes)
  • ghidriff exit: Some(0)
  • Output: C:/Users/tyler/patchwatch/ghidriff\mmc
  • Diff: 209 added, 17 deleted, 3544 modified functions
    • Added: TraceLoggingRegisterEx_EventRegister_EventSetInformation, tlgEnableCallback, Write<struct__tlgWrapSz<unsigned_short>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapperByVal<4>,struct__tlgWrapperByVal<8>>, Write<struct__tlgWrapperByVal<4>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapperByVal<4>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapperByVal<4>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapperByVal<8>>, Write<struct__tlgWrapperByVal<4>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapperByVal<4>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapperByVal<8>>, tlgKeywordOn, tlgWriteTransfer_EventWriteTransfer, Write<struct__tlgWrapperByVal<4>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapperByVal<4>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapperByVal<8>>, Write<struct__tlgWrapperByVal<4>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapSz<unsigned_short>,struct__tlgWrapperByVal<4>,struct__tlgWrapperByVal<8>>, `dynamic_initializer_for_'initlocks''
    • Modified (first 10): AddRef, ~CListIntegrityProtector, Render, get_ListItems, getNextVisibleChildFromStartIndex, Drop, ~CComQIPtr<struct_IOleControl,&struct__GUID_const_IID_IOleControl>, ScGetNode, appendChild, OnNewElement

Pocsmith

With a diff report in hand and a desire to see what a coding agent could do with it, I decided to build out a POC-generating harness rather than simply asking Claude "exploit this patch, make no mistakes," for two reasons: First, I wanted the agent to operate in a defined environment with a defined toolset so it wouldn't wander off on tangents or burn half its budget setting things up each run. Second, I wanted the harness to be portable enough to eventually run unattended.

I opted to use the Claude Agent SDK rather than a full custom harness. Most of the work is in the toolset the agent reaches for, not the loop wrapping it, and I had no interest in reinventing that loop.

The environment is what I'd use myself if I were writing a Windows user-mode or kernel POC by hand: a KDNET-attached Hyper-V VM, a static-analysis tool (Ghidra, because the project already exists from the diffing step), a C compiler, and some basic VR tools (impacket, sysinternals, a Python venv). I wrapped each capability in its own MCP server so the pieces stay reusable outside this harness:

  • hyperv-mcp handles VM lifecycle and guest execution. The agent uses it to take and restore checkpoints, copy files into the guest, run the POC as a non-elevated victim user, and reboot between attempts.
  • kd-mcp is the kernel debugger wrapper. It's a thin layer around kd.exe exposing 22 tools for the standard attach / breakpoint / read / step / resume cycle, plus a raw escape hatch for anything not covered by typed tools. The agent uses it to set breakpoints, observe execution at the patched code, and interpret crash dumps when an attempt produces a bugcheck.
  • pyghidra-mcp handles static analysis of the pre-patch binary. The agent consults Ghidra to understand the vulnerable function's structure, locate specific offsets, and verify its understanding of the code before writing the POC. The only third-party MCP in the stack, credit again to @clearbluejar.
  • pocsmith-mcp contains the driver tools specific to this harness: compile_c (invokes MSVC via vcvarsall and returns structured errors), attacker_py (runs scripts in a venv with security-research libraries pre-installed), and state controllers like record_attempt, report_outcome, and end_phase.

Each MCP gets wired into the agent through a per-workspace .mcp.json. PatchWatch also drops the diff report, the Ghidra project, and the cached pre/post-patch binaries into that workspace so the agent doesn't have to bootstrap any of it.

With the environment built, the kickoff is straightforward. The agent starts in the workspace folder and is given one of three target POC levels to achieve:

  • Level A is crash reproduction. Does triggering the bug cause a kernel bugcheck or an exploitable exception?
  • Level B is a controlled primitive. Can the crash be shaped into a reliable read/write or controlled corruption?
  • Level C is a full exploit. Can the primitive be converted into code execution or privilege escalation?

Each level has its own time, iteration, and dollar budget in pocsmith.yaml, and the agent is told which level it's targeting at the start of each phase. These ceilings are hard limits. For an unattended tool running against a live kernel debugger, you want explicit resource caps.

The session itself is structured as a sequence of phases, each one a bounded Agent SDK run. Phases exist because context windows are finite and exploit research is iterative. A phase boundary is a checkpoint where the agent writes its working state to notes.md, which persists across phases and gives the next session continuity without dragging the full transcript forward. The agent's own notes are its memory; Pocsmith doesn't summarize or interpret them.

Each phase represents a hypothesis. Once it has been tested and recorded, the agent resets its context window and starts fresh. When success is achieved at the target level, the agent restores the VM to a clean state and re-runs the POC in a verification pass to confirm the result wasn't a fluke. Only after the signal matches on two independent runs does Pocsmith promote the artifacts to the artifacts/ directory and call a reporting LLM via report_outcome to produce the final summary, like the one in the intro to this post.

All workspace artifacts are left in place between runs. A human can drop into the workspace, kick off a Claude instance that inherits the MCP configuration, and do manual work whenever the agent gets stuck. Pocsmith can be stopped and resumed at any time with --resume, and the user can inject context with --hint.

Token cost

Building and iterating against the CVE described here cost me just over $300 USD in API tokens, plus the Team subscription I used to build the systems themselves. Most of the spend was Opus-4.7.

I haven't tried optimizing the token cost yet. There are obvious quick wins on the diffing side (prompt caching across iterative diff passes to start), and a tiered model selection (Haiku for triage, Sonnet for synthesis, Opus only where it's really needed) would knock another chunk off. Eventually I would like to decouple this from Anthropic entirely and allow for arbitrary model selection.

So what?

There has been a lot of noise around Mythos since the announcement. The demos are impressive, but companies like XBOW and Aisle have been producing comparable results consistently for over half a year. Large-scale AI-assisted intrusions, like the operation against Mexican government organizations earlier this year, have already happened. Google Threat Intelligence Group is tracking an uptick in AI involvement across every stage of the kill chain, and just reported the first attempted use of an AI-developed zero day in the wild.

The pipeline I built isn't a frontier-model killer, and it won't replace dedicated exploit-development shops. But cyber-capable AI isn't gated behind altruistic frontier labs either. The capability is here, the building blocks are public, and stitching them into an N-day production line is well within reach of one researcher with a couple VMs and a credit card.

One final caveat: The agent's first verified exploit for CVE-2026-27914 demonstrated a real MOTW bypass but didn't quite hit the security boundary MSRC scored the bug against. MSRC rated it PR:L / UI:N; the agent's chain landed at PR:H / UI:R. The system is still a work in progress and isn't producing high-quality exploits yet. It's producing useful and often correct N-day repros that need a human to sharpen the framing on the way out, but with some refinement I believe that gap can be bridged pretty quickly.

I am open-sourcing these tools to be available for researchers and defenders to use, learn from and iterate on. Please note they are for research purposes and I make no claims about the validity of the output, everything should be independently verified.

What to do about it

Both NCSC and the Cloud Security Alliance recently put out advisories on what organizations should actually do about the coming "patch wave." A few of their main points, reframed:

  • Patching is a race now. The window between a Patch Tuesday release and weaponization is closing fast. Default to automatic patching anywhere you can tolerate it. For the systems where you can't, shipping critical fixes and handling the service disruptions required to do so should be the expectation, not the exception.
  • Prioritize outward-facing technologies. Edge appliances and internet-facing services are the most exposed to 0-day and N-day attacks alike. A pipeline like this one can be used against anything someone on the outside can see.
  • Remember you can't protect what you don't know: the unglamourous work of inventory auditing comes before prioritization.
  • Use the same class of agents to protect your code, pipelines, and services before someone else does it for you.

Zooming out from the tactics: the "patch faster, AI faster" framing of the Mythos response is a band-aid, not a sustainable solution. The CSA brief is blunt about it: "we cannot outwork machine-speed threats", and most of the work that keeps an organization defensible in a "post-Mythos" world doesn't happen in the patch race. It happens by employing the same security recommendations we've always had: shrinking the internet-facing surface so fewer of these CVEs are reachable to begin with, segmenting and privilege-bounding the network so one exploited endpoint doesn't cascade into an intrusion, and investing in the detection-and-response that catches what the vendor patch missed.

The other temptation to avoid is duplicating the vendor's work. Microsoft's new MDASH system is the upstream side of this curve, orchestrating 100+ agents to surface Windows CVEs at a scale no single security org is going to match. Use the components above for variant analysis on your own code where it pays off, but don't pretend you're going to out-research the people upstream of you. Shipping the patch is the vendor's job. Staying defensible in the gaps between is yours.

Bonus

For those of you who made it to the end, here is a special bonus report, a level A crash reproduction report of CVE-2026-41096 from May's Patch Tuesday:

CVE-2026-41096-level-a.mdSHOW

CVE-2026-41096 -- Windows DNS Client Remote Code Execution Vulnerability

CVSS: 9.8 | KB: KB5089548 | Level: crash_repro_success

Executive Summary

CVE-2026-41096 is classified by Microsoft as a heap-based buffer overflow in the Windows DNS client allowing remote, unauthenticated code execution (CVSS 9.8, AV:N/AC:L/PR:N/UI:N). KB5089548 addresses the issue by modifying multiple binaries; patch diffing against the listed primary binaries (ws2_32.dll and webio.dll) revealed two distinct hardening patterns: (1) replacement of an unsafe manual wide-string NUL search in ws2_32.dll!WSCGetApplicationCategoryEx and related helpers with a new bounded helper StringLengthWorkerW, and (2) introduction of explicit integer-overflow guards and a larger per-entry structure (0x48 -> 0x50) around heap allocations in webio.dll!HkAddPairToTable (HPACK header table management).

This report documents a Level A crash-repro against the ws2_32.dll surface. A locally-callable reproducer exercises pre-patch ws2_32.dll!WSCGetApplicationCategoryEx with an environment-variable expansion ("%PATH%" x 8) that fully fills the function's 0x104-wchar stack buffer without leaving a NUL terminator. The unsafe in-function NUL-search loop returns the maximum length (0x104), which is then passed as a wchar count to ws2_32!ConvertWStrToHash. ConvertWStrToHash reads past the end of the underlying buffer, crosses a page boundary into unmapped memory, and crashes with EXCEPTION_ACCESS_VIOLATION (0xC0000005) at ws2_32!ConvertWStrToHash+0x50 (RVA 0xB2A0). The faulting call site is the exact loop that becomes a StringLengthWorkerW call in the patched build, confirming the crash sits on the patched code path.

The local reproducer does not by itself prove the full network-reachable DNS-client RCE primitive that Microsoft's advisory describes; it does demonstrate that the pre-patch ws2_32.dll changes that ship in KB5089548 fix a real out-of-bounds read tied to incorrect string-length computation, on the patched call path. The webio.dll!HkAddPairToTable changes (HPACK integer overflow with allocation-size bump from 0x48 to 0x50) remain the most likely DoH/HTTP-layer surface for the original remote-network primitive but were not reached by this Level A repro.

Vulnerability Analysis

Affected Component

  • Primary patched binaries (per KB5089548): ws2_32.dll, webio.dll.
  • Crash reproducer hits ws2_32.dll, specifically:
    • ws2_32.dll!WSCGetApplicationCategoryEx (exported user-mode API, in the LSP / Winsock catalog category-lookup path).
    • ws2_32.dll!ConvertWStrToHash (downstream sink that consumes the computed string length).
  • Pre-patch ws2_32 build: 10.0.26100.8328. Patched build (per notes): 10.0.28000.1896.
  • Pre-patch ws2_32.dll SHA256: A2E6BFA0D7ED958DDCDDB9FFF362838A1D8C43DEA28F2196167DBA28D6E09F71.
  • Additional security-relevant binary: webio.dll!HkAddPairToTable (HPACK header-table allocator). Pre-patch SHA256: 39630C93A39B57078DA73E4611748A54AFFB9696133ECA4DE3700C997314ABF6.

Root Cause

Two distinct unsafe-length / unsafe-arithmetic patterns are addressed by the patch. The crash repro exercises the first.

  1. ws2_32.dll!WSCGetApplicationCategoryEx -- manual unbounded NUL-walk over a fixed 0x104-wchar buffer:

    • The function declares a local wchar buffer (local_248) of 0x104 wide characters and calls ExpandEnvironmentStringsW(Path, local_248, 0x104) into it.
    • It then manually walks the buffer with a decrementing counter starting at 0x104 looking for a terminating NUL.
    • The buffer length passed to ExpandEnvironmentStringsW is the same 0x104 cap. ExpandEnvironmentStringsW can fill the destination buffer to capacity in cases where the expanded result is at least as large as the destination, leaving the destination without a guaranteed NUL terminator in the in-buffer portion the manual walk inspects.
    • When the walk finds no NUL within the 0x104-wchar window, the counter reaches 0 and the function computes the effective length as 0x104 - 0 = 0x104, i.e. the maximum.
    • That length is then fed (via ConvertWStrToHashNoDrive / ConvertWStrToHash) into a routine that reads length wide chars from the buffer in order to fold them into a hash. Because the underlying stack buffer is only 0x104 wchars and there is no slack on either side guaranteed mapped, the read can cross a page boundary off the end of the buffer into unmapped memory and fault. With page-heap or adjacent heap chunks instead of a stack buffer, the same off-by-the-NUL-terminator length error produces an out-of-bounds read against the heap.
    • The same unsafe pattern exists in ws2_32!InitAppCategoryInfo (the non-drive prefix hashing path for the module file path), with identical replacement to StringLengthWorkerW.
  2. webio.dll!HkAddPairToTable -- missing integer-overflow checks around HPACK header-table allocation size:

    • Pre-patch the function computes a size in the form uVar3 = uVar5 + *(int *)((longlong)param_2 + 0x14); ... allocate uVar3 + 0x48 with only loose ordering checks (e.g. uVar5 <= uVar3 and uVar3 <= uVar3 + 0x48) that can be defeated by adversarial inputs that wrap unsigned 32-bit arithmetic. A wrapped sum produces a small allocation followed by a memset/write sized from a separate (larger) untrusted field, i.e. a classic CWE-122 heap buffer overflow.
    • The header-table entries are populated from HPACK-encoded HTTP/2 header data. The Windows HTTP stack is exercised by the Windows DNS client when DNS-over-HTTPS (DoH) is enabled, which is a plausible network-reachable trigger for a malicious server response.

Patch Analysis

The patch makes two coordinated sets of changes:

ws2_32.dll (string-length hardening):

  • Introduces a new helper StringLengthWorkerW(psz, cchMax, *pcchLength) -> HRESULT that bounds the walk to cchMax characters and returns E_INVALIDARG (0x80070057) on overflow.
  • Rewrites WSCGetApplicationCategoryEx to call StringLengthWorkerW instead of its inline NUL-walk, then to honor the HRESULT (zeroing the length on failure) before passing it downstream.
  • Applies the same pattern in InitAppCategoryInfo.
  • Replaces direct _vsnwprintf calls in StringCchPrintfW and LogStringPrintf with StringValidateDestW + StringVPrintfWorkerW, hardening formatted-string paths against truncation/overflow edge cases.
  • Adds a reentrancy guard in GetContextAndNotifyFailure and a feature-flag-gated race-safe spin-wait in GetNamespaceCalloutRoutine (ancillary hardening).

webio.dll (HPACK heap-allocation hardening):

  • Rewrites HkAddPairToTable with explicit unsigned-overflow guards on every additive size computation, each returning STATUS_INTEGER_OVERFLOW (0xC0000095) on wrap.
  • Bumps the per-entry trailer size from 0x48 to 0x50 bytes, with corresponding offset updates in HkInitializeNameHash, HkEncode, and HkDecodeLiteral (entry stride and base offsets shifted from 0x7c8 to 0x9b8, pointer arithmetic shifts from puVar3 + -4 to puVar3 + -5).
  • Introduces HkSearchTableByNameValue and HkAddPairToNameHash as factored helpers consistent with the structural change.
  • Adds a UxKirHkPerformanceImprovements-gated alternate code path (orthogonal to the security fix).

The remaining changes across nsi.dll, dnsclientpsprovider.dll, and urlmon.dll are dominated by WIL / EnabledStateManager telemetry-infrastructure refactors and are not security-relevant to this CVE.

Exploit Chain

For the demonstrated Level A crash on ws2_32.dll:

  1. Local user-mode process loads ws2_32.dll and resolves WSCGetApplicationCategoryEx via GetProcAddress.
  2. Caller passes a Path argument containing repeated environment-variable references that the function will expand via ExpandEnvironmentStringsW into its internal 0x104-wchar buffer.
  3. The expanded result fills the 0x104-wchar buffer to capacity without an in-window NUL terminator.
  4. The unsafe inline NUL-walk loop exhausts its decrementing counter and returns the maximum length (0x104).
  5. The bad length is consumed by ConvertWStrToHashNoDrive -> ConvertWStrToHash, which reads length wide chars from the buffer.
  6. The read crosses the end of the underlying buffer; if the next page is unmapped, the process crashes with ACCESS_VIOLATION (the demonstrated outcome). In an alternative memory layout, the read would silently disclose adjacent memory and the resulting hash would be computed from attacker-influenced out-of-bounds data; subsequent uses of that hash (e.g. as a registry-key index for the LSP/namespace category lookup) could be steered.

For the network-reachable RCE primitive Microsoft's advisory describes, the most plausible chain (not demonstrated in this repro):

  1. Victim Windows DNS client issues a DoH (DNS-over-HTTPS) request, causing the WinHTTP / webio.dll HTTP/2 stack to negotiate HPACK.
  2. Attacker-controlled or man-in-the-middled DoH server returns a crafted HEADERS frame whose dynamic-table sizing causes the unguarded additive arithmetic in HkAddPairToTable to wrap 32-bit.
  3. Wrapped sum produces a small heap allocation; subsequent memset/copy uses the un-wrapped (large) length, producing a heap-based buffer overflow (CWE-122) in the webio.dll heap, leading to remote code execution at the privilege of the DNS-client component.

Confidence that the webio.dll HkAddPairToTable change is the network-reachable RCE primitive: moderate. It is the highest-relevance security change in the patched binary set (0.95), matches CWE-122 and the "over a network" wording, and the allocation-size bump from 0x48 to 0x50 is the kind of structural fix attached to a real overflow. Confidence that the ws2_32 WSCGetApplicationCategoryEx fix is the network-reachable RCE primitive: low; it is locally callable but the input is the calling process's own string.

Proof of Concept

Requirements

  • Windows host on the pre-patch build for KB5089548. Tested target build: 10.0.26100.8328 of ws2_32.dll on hostname nopeskope (VM checkpoint pocsmith-CVE-2026-41096).
  • Local user account able to run a binary from C:\Windows\Temp (no elevation required).
  • Recommended: HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps configured for poc.exe so the crash dump is captured automatically. (Pre-configured on the test VM.)
  • Recommended: gflags page-heap enabled on poc.exe to catch any heap-allocation variant of the same length error in adjacent code paths.
  • Build: any MSVC toolchain that links ws2_32.lib (the POC actually resolves WSCGetApplicationCategoryEx dynamically via GetProcAddress, so static linkage is not required).

Reproduction Steps

  1. Revert the test VM to the pocsmith-CVE-2026-41096 checkpoint (pre-patch ws2_32.dll, SHA256 A2E6BFA0...09F71).
  2. Build poc.c (see Appendix D) to produce poc.exe.
  3. Copy poc.exe to C:\Windows\Temp\poc.exe.
  4. From an interactive shell on the VM, run: C:\Windows\Temp\poc.exe 1. Mode 1 builds a path string "%PATH%%PATH%%PATH%%PATH%%PATH%%PATH%%PATH%%PATH%" and calls WSCGetApplicationCategoryEx with a context-pair pointer that selects the ExpandEnvironmentStringsW branch.
  5. The process should terminate immediately. A user-mode minidump appears under the configured LocalDumps directory.
  6. Optional: open the dump in cdb / WinDbg and confirm the faulting instruction is movzx ecx, word ptr [rbx] at ws2_32!ConvertWStrToHash+0x50, called from ws2_32!WSCGetApplicationCategoryEx+0x207.

Expected Signals

  • Exit reason: usermode_exception.
  • Module: ws2_32.dll.
  • Exception code: 0xC0000005 (EXCEPTION_ACCESS_VIOLATION, read).
  • Faulting RIP: ws2_32!ConvertWStrToHash+0x50, RVA 0xB2A0 (within rva_range [0xB200, 0xB300]).
  • Faulting register: rbx = 0x4195f00000 (page-aligned, unmapped) on the captured run.
  • Call stack: ws2_32!ConvertWStrToHash <- ws2_32!WSCGetApplicationCategoryEx+0x207 (the patched call site).
  • Reproducibility: deterministic across runs on the captured pre-patch build.

Detection & Mitigation

Mitigation:

  • Install KB5089548. The patch replaces the unsafe NUL-walk in WSCGetApplicationCategoryEx and InitAppCategoryInfo with StringLengthWorkerW and adds integer-overflow guards plus a per-entry size bump in webio.dll!HkAddPairToTable.
  • Where DNS-over-HTTPS is in use and the patch cannot be applied immediately, restrict DoH endpoints to trusted resolvers (TLS-pinned, controlled by the enterprise) to reduce the attack surface for the webio.dll HPACK path.

Detection:

  • File-version / hash: assert that ws2_32.dll and webio.dll on managed endpoints are at or above the patched build (10.0.28000.1896 per the test environment). Pre-patch ws2_32.dll SHA256 A2E6BFA0D7ED958DDCDDB9FFF362838A1D8C43DEA28F2196167DBA28D6E09F71 and pre-patch webio.dll SHA256 39630C93A39B57078DA73E4611748A54AFFB9696133ECA4DE3700C997314ABF6 indicate an unpatched host.
  • Crash telemetry: WER reports listing the faulting module as ws2_32.dll with faulting function ConvertWStrToHash and a caller of WSCGetApplicationCategoryEx are a strong indicator of this issue (the post-patch helper StringLengthWorkerW would not produce that stack).
  • Network telemetry for the webio.dll branch: anomalous HTTP/2 HEADERS frames against DoH resolver endpoints, particularly with extreme HPACK dynamic-table-size updates or unusually large name/value lengths, warrant inspection.

Caveats & Limitations

  • This report demonstrates a Level A crash-reproducer (deterministic ACCESS_VIOLATION on the patched call path) and not a full RCE.
  • The demonstrated crash uses a process-local input (the calling process's own Path argument). It does not by itself prove a remote-attacker-controlled trigger. Microsoft's advisory wording ("over a network", AV:N/PR:N) is more consistent with the webio.dll!HkAddPairToTable HPACK overflow than with the ws2_32 string-length issue exercised here; the ws2_32 fix is nonetheless a real out-of-bounds read on a patched code path.
  • The webio.dll!HkAddPairToTable overflow is identified by patch diffing as the most security-relevant change (relevance 0.95) and matches CWE-122 and the network-reachable description, but a network-reachable trigger was not built or executed for this report.
  • The crash repro currently produces an out-of-bounds read that faults; whether the same primitive yields a controllable heap-overflow write (and therefore exploitable RCE) under different memory layouts, or via a separate sink that consumes the bad length, is not characterized here.
  • All findings are based on the patch diff data, the deep-analysis records, and the captured Level A signal supplied in the user message. No additional reverse engineering of patched binaries was performed for this report.
  • Pre-patch ws2_32.dll build under test was 10.0.26100.8328; KB5089548 may ship variants for other Windows servicing branches that were not examined.

Appendix A: Binary Hashes

BinaryStateSHA256 / Build
ws2_32.dllPre-patch (under test)A2E6BFA0D7ED958DDCDDB9FFF362838A1D8C43DEA28F2196167DBA28D6E09F71 (build 10.0.26100.8328)
ws2_32.dllPre-patch (diff input, KB cache)49434f1ba0731070e9b7fdf39b2cf03758f944abbd4d17f515914abc9ded45be (534600 bytes)
ws2_32.dllPatched (diff input, KB cache)4a6ba50e53ab3eac1e9384ce5f3e8e69f4c9fc8505bcef000517f5caf74b931a (538768 bytes)
webio.dllPre-patch (under test)39630C93A39B57078DA73E4611748A54AFFB9696133ECA4DE3700C997314ABF6
webio.dllPre-patch (diff input, KB cache)b430374b545900cc7dd2965f2d89cd322019b145e81f8975c8ae7c3ca2402963 (840344 bytes)
webio.dllPatched (diff input, KB cache)8dd56c8d5668e56183398c428e4b67287c45be853db90bb23a358a77a5cc7146 (848608 bytes)
nsi.dllPre-patch (KB cache)ca38a3890a8fa6cfc73fa72ff0e2cdf6a1f0e05cad85448ad67bfb07d0bbd0c6 (51160 bytes)
nsi.dllPatched (KB cache)153c113de3b981c3c05cee28ec77b2273d0bfe0fd90778dd34f530467908a792 (51192 bytes)
dnsclientpsprovider.dllPre-patch (KB cache)7668e80d20813e847b5f1c315a2f58a1426959a5f444c10a3358bd5bb11435eb (241176 bytes)
dnsclientpsprovider.dllPatched (KB cache)e5c3da423e0ad064281607ea5da1704a89d00ac144221bcb2cdfb64f52eebd64 (241200 bytes)
urlmon.dllPre-patch (KB cache)6882a0db558948024a83d596adf2f15e1863c62d27d85436942c1ced06e1fb44 (1916928 bytes)
urlmon.dllPatched (KB cache)5748cde7ade6040456ea110d9cf6b5b82efd6dad9584008814b791411ca50edd (1921024 bytes)

Patched build identifier on the test VM: 10.0.28000.1896.

Appendix B: Key Patch Diff

ws2_32.dll!WSCGetApplicationCategoryEx (relevance 0.85)

Before:

lVar9 = 0x104;
pWVar4 = local_248;
do {
  if (*pWVar4 == L'\0') break;
  pWVar4 = pWVar4 + 1;
  lVar9 = lVar9 + -1;
} while (lVar9 != 0);
_Var7 = -(ulonglong)(lVar9 != 0) & -(ulonglong)(lVar9 != 0) & 0x104U - lVar9;
param_6 = local_298;
if (lVar9 != 0) goto LAB_180030877;

After:

HVar2 = StringLengthWorkerW(local_248, 0x104, &local_2a8);
sVar12 = local_2a8;
if (HVar2 < 0) {
  sVar12 = _Var10;
}
param_6 = local_2a0;
if (HVar2 == 0) goto LAB_180030900;

The manual decrementing NUL walk is replaced with a bounded helper that returns an HRESULT. The post-patch caller honors the HRESULT before propagating the length to the downstream hash routine.

ws2_32.dll!StringLengthWorkerW (new helper, relevance 0.82)

HRESULT __stdcall StringLengthWorkerW(STRSAFE_PCNZWCH psz, size_t cchMax, size_t *pcchLength)
{
  uVar1 = ~-(uint)(sVar2 != 0) & 0x80070057;
  if (pcchLength != (size_t *)0x0) {
    if (sVar2 == 0) {
      *pcchLength = 0;
      return uVar1;
    }
    *pcchLength = cchMax - sVar2;
  }
  return uVar1;
}

Returns E_INVALIDARG (0x80070057) when the input is not NUL-terminated within cchMax characters. This is the safe-string primitive introduced by the patch.

ws2_32.dll!InitAppCategoryInfo (relevance 0.75)

Before:

uVar7 = uVar11;
pwVar10 = pwVar6;
if (uVar2 < 0x80000000) {
  do {
    if (*pwVar10 == L'\0') break;
    uVar7 = uVar7 - 1;
    pwVar10 = pwVar10 + 1;
  } while (uVar7 != 0);
  if (uVar7 != 0) {
    uVar12 = uVar2 - (int)uVar7;
  }
}

After:

local_268 = 0;
if (uVar2 < 0x80000000) {
  HVar3 = StringLengthWorkerW(psz, cchMax, &local_268);
  uVar2 = (ulong)local_268;
  if (-1 < HVar3) goto LAB_180012c7a;
}
uVar2 = 0;

Same pattern: unsafe inline walk replaced by bounded helper, with the length forced to zero on overflow rather than silently passed downstream.

webio.dll!HkAddPairToTable (relevance 0.95)

Before:

uVar2 = *(int *)((longlong)param_2 + 0x14) + uVar12;
if ((uVar12 <= uVar2) && (uVar2 <= uVar2 + 0x20)) {
  uVar12 = *(uint *)(param_1 + 0x20);
  if ((uVar12 <= uVar12 + 1) &&
     (((0x3c < uVar12 + 0x3d &&
       (uVar3 = uVar5 + *(int *)((longlong)param_2 + 0x14), uVar5 <= uVar3)) &&
      (uVar3 <= uVar3 + 0x48)))) {
    _Dst = (longlong *)_guard_dispatch_icall_thunk_10345483385596137414();
    if (_Dst != (longlong *)0x0) {
      memset(_Dst, 0, (ulonglong)(uVar3 + 0x48));
      _Dst[6] = (longlong)(_Dst + 9);
      _Dst[7] = (ulonglong)*(uint *)(param_2 + 2) + 0x48 + (longlong)_Dst;

After:

uVar8 = *(uint *)(param_1 + 4) + uVar1;
if (uVar8 < *(uint *)(param_1 + 4)) {
  return 0xc0000095;
}
uVar5 = *(int *)((longlong)param_2 + 0x14) + uVar8;
if (uVar5 < uVar8) {
  return 0xc0000095;
}
uVar8 = uVar5 + 0x20;
if (uVar8 < uVar5) {
  return 0xc0000095;
}
uVar12 = *(uint *)(param_1 + 0x20);
uVar5 = uVar12 + 1;
if (uVar5 < uVar12) {
  return 0xc0000095;
}
...
uVar6 = uVar1 + *(int *)((longlong)param_2 + 0x14);
if (uVar6 < uVar1) {
  return 0xc0000095;
}
if (uVar6 + 0x50 < uVar6) {
  return 0xc0000095;
}
_Dst = (longlong *)_guard_dispatch_icall_thunk_10345483385596137414();
if (_Dst == (longlong *)0x0) {
  return 0xc000009a;
}
memset(_Dst, 0, (ulonglong)(uVar6 + 0x50));
_Dst[7] = (longlong)(_Dst + 10);
_Dst[8] = (ulonglong)*(uint *)(param_2 + 2) + 0x50 + (longlong)_Dst;

Every additive size computation now has an explicit unsigned-wrap check returning STATUS_INTEGER_OVERFLOW (0xC0000095). The per-entry trailer grows from 0x48 to 0x50 bytes, and the downstream HkInitializeNameHash / HkEncode / HkDecodeLiteral functions are updated to match (entry base offset 0x7c8 -> 0x9b8, pointer arithmetic puVar3 + -4 -> puVar3 + -5).

Appendix C: Debugger Output

Captured signal (from the crash repro):

EXCEPTION_CODE: 0xC0000005 (ACCESS_VIOLATION read)
RIP:           ws2_32!ConvertWStrToHash+0x50  (RVA 0xB2A0)
faulting insn: movzx ecx, word ptr [rbx]
rbx:           0x4195f00000   (page-aligned, unmapped)
caller:        ws2_32!WSCGetApplicationCategoryEx+0x207
ws2_32 build:  10.0.26100.8328 (pre-patch)
patched build: 10.0.28000.1896
rva_range:     [0xB200, 0xB300]   (covers ConvertWStrToHash)

Signal object (as recorded):

{
  "kind": "usermode_exception",
  "module": "ws2_32.dll",
  "rva_range": [45568, 45824],
  "exception_code": "c0000005"
}

Crash dump: artifacts/poc.exe.crash.dmp (captured via HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps configured for poc.exe). Reproducer is deterministic.

The call site WSCGetApplicationCategoryEx+0x207 is inside the exact function whose manual NUL walk becomes a StringLengthWorkerW call in the patched build, confirming the crash is on the patched code path.

Appendix D: POC File Manifest

FilePurposeLocation on test VM
poc.cSource for the crash reproducerBuild host
poc.exeCompiled reproducer (built from poc.c)C:\Windows\Temp\poc.exe
poc.exe.crash.dmpCaptured user-mode minidump from the Level A runartifacts/poc.exe.crash.dmp (via configured LocalDumps)

poc.c summary:

  • Resolves WSCGetApplicationCategoryEx (and falls back to WSCGetApplicationCategory) via GetProcAddress against ws2_32.dll.
  • Calls WSAStartup(MAKEWORD(2,2), &wd) to initialize Winsock catalog state.
  • Selects one of nine probe modes via argv[1]. Mode 1 is the confirmed crash:
    • Path string: "%PATH%%PATH%%PATH%%PATH%%PATH%%PATH%%PATH%%PATH%"
    • PathLength: wcslen(path) (48)
    • Extra: L"x", ExtraLength: 1
    • Context pair: {0, 0} -- forces the ExpandEnvironmentStringsW branch inside WSCGetApplicationCategoryEx.
  • Other modes (0, 2-8) explore adjacent inputs (full-length literal paths, percent-soup, large explicit PathLength values, etc.) and are useful for variant hunting under page-heap. Mode 1 is sufficient for the documented Level A signal.

Invocation that produces the documented crash:

C:\Windows\Temp\poc.exe 1