Patch diffing Windows binaries is one of the most direct paths into vulnerability research, n-day exploit development, and Windows internals. This post covers how Microsoft’s cumulative updates work under the hood, including the MSU container format, the PSFX v2 layout, the CIX XML index, and forward-delta patches applied via msdelta.dll‘s PA30 format. It also walks through a set of PowerShell scripts I wrote for extracting before-and-after binaries from patch Tuesday MSUs against the RTM base, so you can drop them straight into BinDiff, Diaphora, or any other binary diffing tool.
Performing differential analysis (patch diffing) on the Windows patch Tuesday patches has been one of my favorite ways of learning new topics. The reversing and exploit development challenges are also great ways to work on our skills. Traditionally I would use Winbindex as my source of before and after binaries. However, I have found some issues with it throughout the years. As such, I decided to work on building some helper scripts to gather these myself.
This post will mostly cover the scripts I’ve created, and how to use them. Sprinkled within will be some information as to the internals of Windows patches. These scripts are experimental and will be updated as needed. I will only keep these scripts updated to support the latest update package formats, as that is all I will personally need.
The scripts discussed in this post can be found here: https://github.com/0xZ0F/Windows-Patch-Diff-Helpers
What’s Actually In An MSU
An .msu is a wrapper. Modern Windows cumulative updates use the PSFX v2 format (Progressive Servicing Format eXtended), which is the layout you’ll see when you crack one open. The first four bytes of the MSU tell you which underlying container is in use.
MSCF(Microsoft Cabinet) is the older format. You can extract it withexpand.exeand you don’t need elevation.MSWIMis a Windows Imaging Format archive. You apply it withdism /Apply-Image, which requires an elevated prompt.
The newer cumulative updates I’ve been pulling are all WIM-based. Once you unpack the outer container, you get something that looks like this.
KB5082052.msu
Windows11.0-KB5082052-x64.wim <- inner payload (WIM or CAB)
Windows11.0-KB5082052-x64.psf <- binary blob store
Windows11.0-KB5082052-x64\
express.psf.cix.xml <- index mapping file paths to PSF offsets
The .psf is a flat blob. The CIX XML inside the inner payload is the index that tells you what’s in the blob, where each entry sits, and how to interpret it.
The CIX XML
Each <File> entry in the CIX XML has a path that looks like a Windows component-store path, an offset into the PSF, a length, and a type. The type matters. There are two kinds you care about.
\n\entries are complete (neutral). The PSF blob contains the full binary, raw. You read the bytes at the offset, write them to disk, and you have the file.\f\entries are forward deltas. The PSF blob contains a delta, not a full binary. You need a base file to reconstruct the target.
A trimmed-down entry looks like this. The path encodes the architecture, version, and patch type all at once.
<File name="amd64_microsoft-windows-...\f\10.0.26100.3775_..._installservice.dll">
<Delta>
<Source type="..." offset="..." length="..." />
</Delta>
</File>
The \f\ segment is the marker for forward delta. The version embedded in the path (10.0.26100.3775) is the target version, the binary you’ll get out after the delta is applied.
Forward Deltas And Why They Hurt
When the PSF stores a \f\ entry, the bytes at the recorded offset are not just a delta. They are a 4-byte container prefix followed by an MSDelta PA30 blob. If you hand the raw bytes straight to ApplyDeltaB, it will fail. You have to strip the first four bytes so the magic lines up at byte zero. The scripts handle this automatically.
# In ExtractFiles.ps1
if ($magic -eq 'PA30' -or $magic -eq 'PA31') {
$dltOffset = 4
$dltLength -= 4
}
The delta itself is applied with msdelta.dll, specifically ApplyDeltaB. You give it a source buffer (the base binary), a delta buffer (the PA30 blob), and it returns the patched binary.
The detail that took me the longest to internalize is what the base actually is. Forward deltas in the PSFX format are not chained against the previous patch. Every forward delta is computed against the RTM binary, the one with build number XXX.1. So if you want installservice.dll at 10.0.26100.3775, you don’t need every patch since RTM. You need exactly two things, the RTM installservice.dll (10.0.26100.1) and the delta from the patch you care about. The delta knows how to go straight from RTM to whatever the current version is.
That property is what makes “forward” in the name accurate. It always points forward from the same starting point.
The format is also good for delivery size. The patch only needs to ship the delta from RTM, not a chain. The cost is that every machine has to have the original RTM file present, or the delta cannot be applied.
The Scripts
The repo contains six Powershell scripts.
| Script | Purpose |
|---|---|
ExtractMSU.ps1 |
Unpack a .msu into its inner payload, .psf, and CIX XML. |
ExtractFiles.ps1 |
Pull specific binaries out of the unpacked MSU, applying deltas as needed. |
ExtractUUP.ps1 |
Extract specific files from UUP dump ESD/CAB packages. Used to source RTM base binaries. |
GetChangedFiles.ps1 |
Inventory all files in an unpacked MSU to a CSV. |
CompareChangedFiles.ps1 |
Compare two inventory CSVs and report what changed. |
FindFileVersionHistory.ps1 |
Search all indexed patches for every known version of a file, sorted oldest to newest. |
You will need 7-Zip, plus an Administrator shell if the MSU is WIM-based.
Unpacking The MSU
Grab the MSU from the Microsoft Update Catalog, search by KB, and pick the architecture you want. I like to put each MSU in its own KBXXXXXXX folder so the unpacked output stays organized.
.\ExtractMSU.ps1 -MsuPath .\KB5082052\kb5082052.msu -OutputFolder .\KB5082052\unpacked
The script reads the first four bytes of the MSU to detect the format, then dispatches to dism or expand.exe accordingly. You’ll end up with the inner payload, the .psf, and the CIX XML extracted from the payload into a subfolder named after the payload.
Sourcing The RTM Base
If the file you’re after is a \f\ entry, you need the RTM base. Getting that base is the most fragile part of the workflow. There are three ways, in order of how much I trust them.
UUP Dump (Recommended)
UUP dump captures the component ESDs that Windows Update would deliver to a fresh install. They contain files at specific build versions, and the RTM .1 builds are usually available. Find the build, grab the download script, run it, then point ExtractUUP.ps1 at the output.
.\ExtractUUP.ps1 -UupFolder .\UUPDump\UUPs -OutputFolder .\RTMFiles -Files installservice.dll, msi.dll
The script walks the ESD/CAB packages, prefers component-store paths over image paths so you don’t accidentally end up with a different architecture’s copy, and writes each file out with an arch-prefixed name like amd64_installservice.dll. Language packs are skipped automatically since they only contain .mui files.
ISO (Be Careful)
A real RTM ISO works. The catch is that Microsoft’s publicly distributed ISOs (Media Creation Tool, microsoft.com/software-download) are generally not RTM. They get refreshed with later build numbers. If you do have a true RTM ISO, mount it and pull the file out of sources\install.wim.
dism /Mount-Image /ImageFile:D:\sources\install.wim /Index:1 /MountDir:.\mnt /ReadOnly
# copy the file you need
dism /Unmount-Image /MountDir:.\mnt /Discard
I would not recommend browsing the WIM indexes with 7-Zip directly. The format uses links between indexes, so you may have to search across all 11 of them to find what you need. Mounting is easier.
WinBindex
WinBindex indexes PE files by name and build. It’s quick to use, but I’ve hit two specific problems with it.
- The version metadata shown on the site is sometimes wrong. Always check the actual PE version after you download.
- The displayed hash sometimes doesn’t match the file you get. Verify with
Get-FileHashbefore trusting it.
I still try WinBindex first because it’s the fastest path. When it fails or the file isn’t indexed, I fall back to UUP dump.
Extracting The Binaries
Once you have the unpacked MSU and (if needed) an RTM base folder, you can extract specific files with ExtractFiles.ps1. The -Filter parameter is a wildcard against the full component path in the CIX XML.
For complete files, no base is needed.
.\ExtractFiles.ps1 -MsuFolder .\KB5082052\unpacked -OutputFolder .\KB5082052\dlls -Filter target.dll
For forward-delta files, point the script at your RTM base folder.
.\ExtractFiles.ps1 -MsuFolder .\KB5082052\unpacked -OutputFolder .\KB5082052\dlls -BaseFolder .\RTMFiles -Filter target.dll
The script reports how many \f\ and \n\ entries it matched before doing any extraction. If you forgot the -BaseFolder, the \f\ entries are skipped with a warning instead of failing silently.
A few behaviors worth knowing about.
- MUI satellite files are excluded automatically. They show up in the CIX XML alongside the real binaries and they’re almost never what you want.
- When the same filename exists for multiple architectures (
amd64_target.dllandwow64_target.dll, for example), the output filenames get arch prefixes so they don’t collide. - The
-Filteris matched against the full path, so*.exematches all EXEs,installservice.dllmatches that exact filename, and*.dllmatches everything.
Finding What Actually Changed
You don’t always know the file you want. Sometimes you have a CVE writeup pointing at a subsystem and you need to figure out which DLL to look at. The inventory and compare scripts handle this.
GetChangedFiles.ps1 writes a CSV of every file in an unpacked MSU.
.\GetChangedFiles.ps1 -MsuFolder .\KB5082052\unpacked
# Writes .\KB5082052\unpacked\files.csv
The CSV has FileName, Arch, Version, IsForward, IsMui, and FullPath. You can grep it directly from PowerShell.
Import-Csv .\KB5082052\unpacked\files.csv |
Where-Object { $_.FileName -match 'msi' } |
Select-Object FileName, Arch, Version
CompareChangedFiles.ps1 takes two of those CSVs and shows you the diff.
.\CompareChangedFiles.ps1 -BeforeCsv .\KB5040442\files.csv -AfterCsv .\KB5082052\files.csv
Each row gets a Status of Updated, Added, or Removed, with the before and after versions. MUI files are excluded by default since they spam the output. Pass -IncludeMui if you really want them.
There is a subtle gotcha with the inventory. A file showing up in files.csv does not always mean the patch itself changed it. The CIX XML can include files carried forward from prior patches. The Version column tells you the actual version landed by the patch, so the compare script’s diff is reliable, but if you’re just listing files you may see entries that didn’t change in this specific KB.
Tracking A File Across Patches
FindFileVersionHistory.ps1 walks all files.csv files under a root folder and shows every version of a specific file across all your indexed patches.
.\FindFileVersionHistory.ps1 -RootFolder . -FileName ntoskrnl.exe
3 result(s) for 'ntoskrnl.exe'
KB Arch Version IsForward SourceCsv
-- ---- ------- --------- ---------
KB5040442 amd64 10.0.26100.1 True C:\...\KB5040442\files.csv
KB5078132 amd64 10.0.26100.3194 True C:\...\KB5078132\extracted\files.csv
KB5082052 amd64 10.0.26100.3775 True C:\...\KB5082052\files.csv
This is mostly useful when you’re looking for a “previous” version that isn’t a clean RTM. Take the BeforeVersion from CompareChangedFiles.ps1, find that version in this output, and you know which KB to pull from.
If you’re just patch diffing the latest KB against RTM, you probably don’t need this script at all.
Example
Here is an example conducted for the April 2026 patch Tuesday.
# 1. Unpack both patches
.\ExtractMSU.ps1 -MsuPath .\KB5040442\kb5040442.msu -OutputFolder .\KB5040442\unpacked
.\ExtractMSU.ps1 -MsuPath .\KB5082052\kb5082052.msu -OutputFolder .\KB5082052\unpacked
# 2. Get the RTM base from a UUP dump
.\ExtractUUP.ps1 -UupFolder .\UUPDump\UUPs -OutputFolder .\RTMFiles -Files installservice.dll, msi.dll
# 3. Extract the "before" versions
.\ExtractFiles.ps1 -MsuFolder .\KB5040442\unpacked -OutputFolder .\KB5040442\dlls -BaseFolder .\RTMFiles -Filter installservice.dll
.\ExtractFiles.ps1 -MsuFolder .\KB5040442\unpacked -OutputFolder .\KB5040442\dlls -BaseFolder .\RTMFiles -Filter msi.dll
# 4. Extract the "after" versions
.\ExtractFiles.ps1 -MsuFolder .\KB5082052\unpacked -OutputFolder .\KB5082052\dlls -BaseFolder .\RTMFiles -Filter installservice.dll
.\ExtractFiles.ps1 -MsuFolder .\KB5082052\unpacked -OutputFolder .\KB5082052\dlls -BaseFolder .\RTMFiles -Filter msi.dll
# 5. Diff in BinDiff or Diaphora
# Before: .\KB5040442\dlls\installservice.dll
# After: .\KB5082052\dlls\installservice.dll
Finding The Right KB
Start at the MSRC update guide to find the bug, CVE, and KB you want to look at. The articles will name the KB, but there are different KBs for different versions of Windows.
What I usually do instead is search for site:support.microsoft.com Windows 11 KB, pick the version on the left of the support page, and read off the exact KB. For example, the January 24, 2026 KB5078132 page lists the KB for that specific build. Once you know the KB, the Update Catalog has the actual MSU. Make sure to grab the right architecture.
Troubleshooting
ApplyDelta failed almost always means the base binary is wrong. The most common cause is a base file that isn’t actually RTM. Verify with PowerShell.
(Get-Item .\RTMFiles\target.dll).VersionInfo.FileVersion
If the build number is anything other than XXX.1, that’s the problem. UUP dump usually fixes this, even when WinBindex hands you a “10.0.26100.1” labeled file that turns out to be something else.
Elevation errors when running ExtractMSU.ps1 mean the MSU is WIM-based and dism /Apply-Image couldn’t run. Open PowerShell as Administrator and try again. Cabinet-based MSUs don’t need elevation, so older patches work fine from a regular shell.
Once again, here is a link to the scripts: https://github.com/0xZ0F/Windows-Patch-Diff-Helpers