Reverse engineering Apple’s silent security fixes
Remember Rapid Security Responses (RSR)? Apple introduced RSR in macOS Ventura / iOS 16 to ship urgent security patches outside of full OS updates. It was used exactly once and broke websites because parentheses in the User-Agent header confused half the Internet and was quietly shelved.
With iOS 26.1, iPadOS 26.1, and macOS 26.1, Apple replaced RSR with Background Security Improvements (BSI). The big change: BSI installs silently.
On March 17, 2026, Apple shipped four BSI updates across iOS, iPadOS, and macOS.
| Platform | Version | Build |
+----------+------------+------------+
| iOS | 26.3.1 (a) | 23D771330a |
| iPadOS | 26.3.1 (a) | 23D771330a |
| macOS | 26.3.2 (a) | 25D771400a |
| macOS | 26.3.1 (a) | 25D771280a |
+----------+------------+------------+I grabbed the iOS update, tore it apart with ipsw, and diffed it against the base OS to see what actually changed.
This post walks through how BSI updates work under the hood. More importantly, it shows what Apple actually shipped: one publicly disclosed WebKit CVE, and at least two additional security-relevant changes that didn’t make it into the advisory.
How BSI differs from RSR
Both target the same thing: security patches for Safari, WebKit, and system libraries without a full OS update. Under the hood, both work by patching cryptexes. If you haven’t run into these before: Apple moved content eligible for rapid patching (Safari, WebKit, system libs) into sealed disk images on the preboot volume, split into system and app subtypes. When an update arrives, the device applies a binary diff to the relevant cryptex image, then asks Apple’s signing service for a new Cryptex1Image4 manifest. The main application processor (AP) boot ticket stays untouched. On restart, the kernel bootstraps the patched content with new measurements and trust caches. That’s why these updates work with minimal battery and no re-sealing; they’re patching a sidecar image, not the root filesystem. Apple’s security docs have the full picture.
The following table summarizes the changes between RSR and BSI:
The versioning scheme carries over: a BSI applied on top of iOS 26.3 becomes iOS 26.3.1 (a). These are cumulative, so the next full update (say, iOS 26.4) absorbs all prior BSI fixes.
I will now show you how to analyze the BSI with ipsw.
Downloading a BSI with ipsw
Same as RSR. Use the --rsr flag with the prerequisite --build:
❯ ipsw dl ota --platform ios \
--rsr \
--device iPhone17,1 \
--build 23D8133 \
--output /tmp/BSI
• Getting iOS 26.3.1 OTA build=23D771330a device=iPhone17,1
encrypted=true key=ER+89JD/fR9xK0MwXhPHfkmPRMnAxBNkOF5v8nfGzk0=
model=D93AP type=iOS2631BetaBSI
26.50 MiB / 26.50 MiB [==============================| ✅ ] 30.58 MiB/s26.5 MiB total. A full OTA is 3-17 GB. That size difference is the whole point: small, targeted patches to the cryptex volumes.
The --build flag is the prerequisite build (the base OS the BSI patches on top of), not the BSI build itself. Find the latest build with:
❯ ipsw download ota --platform ios --device iPhone17,1 --show-latest-buildInspecting the BSI OTA
❯ ipsw ota info <BSI>.aea
[OTA Info]
==========
Version = 26.3.1 (a)
BuildVersion = 23D771330a
OS Type = SplatPreRelease
SystemOS = 043-61970-021.dmg
AppOS = 043-62774-021.dmg
RestoreVersion = 23.4.133.77.1,0
PrereqBuild = 23D8133
IsRSR = ✅
Devices
-------
> iPhone17,1_23D771330aPrereqBuild = 23D8133 tells you this is a delta on top of iOS 26.3 build 23D8133. The IsRSR flag is still there because internally Apple still calls this the “Splat” system (SplatOnly in asset metadata). Two separate cryptex DMGs get patched: SystemOS for frameworks and AppOS for apps.
What’s in the package
❯ ipsw ota ls <BSI>.aea -V -b
AssetData/
├── Info.plist # 1.7 kB
├── boot/
│ ├── BuildManifest.plist # 19 kB
│ ├── Firmware/
│ │ ├── 043-61970-021.dmg.root_hash # 229 B
│ │ ├── 043-61970-021.dmg.trustcache # 2.7 kB
│ │ ├── 043-62774-021.dmg.root_hash # 229 B
│ │ └── 043-62774-021.dmg.trustcache # 407 B
│ ├── Restore.plist
│ ├── RestoreVersion.plist
│ └── SystemVersion.plist
├── payload.bom # 38 kB
├── payload.bom.signature
├── payloadv2.bom # 38 kB
├── payloadv2.bom.signature
└── payloadv2/
├── image_patches/
│ ├── cryptex-app # 39 kB
│ ├── cryptex-app-rev # 39 kB
│ ├── cryptex-system-arm64e # 15 MB
│ └── cryptex-system-arm64e-rev # 15 MB
├── data_payload # 12 B
├── firmlinks_payload # 0 B
├── fixup.manifest
├── links.txt # 0 B
├── payload.000 # 78 B
├── payload.000.ecc # 123 B
├── payload_chunks.txt
├── prepare_payload # 12 B
└── removed.txt # 0 BAlmost everything interesting is in payloadv2/image_patches/. cryptex-system-arm64e at 15 MB is the binary patch for the system cryptex (WebKit, Safari, system libraries). cryptex-app at 39 KB patches the app cryptex. The -rev variants are reverse patches for rolling back a BSI to the base OS state.
Under boot/Firmware/, the .root_hash and .trustcache files bind the patched cryptexes into the device’s Secure Boot chain via a separate Cryptex1Image4 manifest.
Patching the cryptex volumes
To apply the patches and get mountable DMGs, use ipsw ota patch rsr. You need the base OTA’s cryptex volumes first, so download the prerequisite OTA (the 7.81 GiB one):
❯ ipsw dl ota --platform ios --device iPhone17,1 --build 23D8133 --output /tmp/OTAs/
• Getting iOS 26.3.1 OTA build=23D8133 device=iPhone17,1
encrypted=true key=P1OahXDSqR+X5Lc63VFT9JDZFtR6cHtIc+ryyJ9kuLs=
model=D93AP type=iOS2631Long
• URL resolved to: 17.253.27.196 (Apple Inc - Chicago, IL. United States)
7.81 GiB / 7.81 GiB [==============================| ✅ ] 59.81 MiB/sExtract the base cryptex volumes from it:
❯ ipsw ota patch rsr <base_ota>.aea --output /tmp/PATCHES/
• Patching cryptex-app to /tmp/PATCHES/23D8133__iPhone17,1/AppOS/094-25810-058.dmg
• Patching cryptex-system-arm64e to /tmp/PATCHES/23D8133__iPhone17,1/SystemOS/094-26339-058.dmgNow apply the BSI patch on top:
❯ ipsw ota patch rsr --input /tmp/PATCHES/23D8133__iPhone17,1/ \
--output /tmp/PATCHES/ \
<BSI>.aea
• Patching cryptex-app to /tmp/PATCHES/23D771330a__iPhone17,1/AppOS/043-62774-021.dmg
• Patching cryptex-system-arm64e to /tmp/PATCHES/23D771330a__iPhone17,1/SystemOS/043-61970-021.dmgYou now have the patched cryptex DMGs. Mount and poke around:
❯ open /tmp/PATCHES/23D771330a__iPhone17,1/SystemOS/043-61970-021.dmg
❯ find /Volumes/*Cryptex*/ -name “dyld_shared_cache*”NOTE: ipsw ota patch rsr requires macOS 13+ because it calls RawImagePatch in libParallelCompression.dylib to apply the binary image diffs. This is a private API I reversed with no public header.
Diffing the BSI
Now the fun part. I’ve updated ipsw diff to work directly with patched OTA directories:
❯ ipsw diff /tmp/PATCHES/23D8133__iPhone17,1 \
/tmp/PATCHES/23D771330a__iPhone17,1 \
--files --output /tmp/DIFF --markdown
• Mounting patched OTA DMGs
• Mounting ‘Old’ patched OTA DMGs
• Mounting AppOS DMG
• Mounting /tmp/PATCHES/23D8133__iPhone17,1/AppOS/094-25810-058.dmg
• Mounting SystemOS DMG
• Mounting /tmp/PATCHES/23D8133__iPhone17,1/SystemOS/094-26339-058.dmg
• Mounting ‘New’ patched OTA DMGs
• Mounting AppOS DMG
• Mounting /tmp/PATCHES/23D771330a__iPhone17,1/AppOS/043-62774-021.dmg
• Mounting SystemOS DMG
• Mounting /tmp/PATCHES/23D771330a__iPhone17,1/SystemOS/043-61970-021.dmg
• Diffing DYLD_SHARED_CACHES
• Diffing MachOs
• Diffing Files
• Creating diff file Markdown READMEIt mounts both sets of cryptex DMGs, diffs the dyld_shared_cache, individual MachOs, and the file trees, then writes a Markdown report. The full diff output is on GitHub.
NOTE: ipsw diff operates at the symbol level, not the instruction level. It reports added/removed symbols, function count changes, and section size deltas -- but it will miss changes inside a function whose signature didn’t change. For example, the CVE-2026-20643 fix added 46 instructions to innerDispatchNavigateEvent without changing its symbol name, so the diff report doesn’t flag it at all. To catch those, you need to decompile the actual functions (IDA Pro, Ghidra, or ipsw dsc disass --dec, for now 😏) and compare the pseudocode. The diff is a great starting point for triage, but it’s not the full picture.
So what did Apple actually change?
WebKit version bump
+----------------------+--------------+
| | Version |
+----------------------+--------------+
| Base (23D8133) | 623.2.7.10.4 |
| BSI (23D771330a) | 623.2.7.110.1|
+----------------------+--------------+That’s the Safari/WebKit version going from 7623.2.7.10.4 to 7623.2.7.110.1.
NOTE: Normally ipsw dsc webkit --git resolves a DSC’s WebKit version to the exact public git tag on github.com/WebKit/WebKit, giving you a clean git diff between two tags. Here, neither version had an exact match and both fell back to the closest tag WebKit-7623.1.14.14.11 from November 2025. My guess is Apple ships BSI builds from an internal branch that never gets tagged publicly. I had to find the fix commit manually (more on that below).
Updated binaries in AppOS (6)
All Safari-related:
AuthenticationServicesAgent: handles web authentication flowscom.apple.Safari.Historypasswordbreachd: checks passwords against breach databasessafarifetcherd: prefetching/background loadingwebbookmarksd: bookmark sync daemonwebinspectord: Web Inspector remote debugging
Every one got the same version bump 7623.2.7.10.4 -> 7623.2.7.110.1). The changes are mostly in __TEXT.__info_plist sizes (a few bytes larger) and new UUIDs. The actual code sections didn’t change in these binaries, so the AppOS patch is just version metadata and plist updates.
Updated dylibs in the dyld_shared_cache (6)
The dyld_shared_cache is where the actual code changes live. Six dylibs changed:
WebCorelibANGLE-shared.dylibWebGPUProductKitProductKitCoreSettingsFoundation.
I opened both DSC versions in IDA Pro (using open_dsc to load individual modules) and decompiled the changed functions.
CVE-2026-20643: Navigation API Same-Origin bypass
Apple’s security advisory describes one fix:
WebKit -- A cross-origin issue in the Navigation API was addressed with improved input validation.
CVE-2026-20643 -- Thomas Espach
The Navigation API window.navigation) lets JavaScript intercept and control navigations within a page. The property that matters here is NavigateEvent.canIntercept because it tells a script whether it’s allowed to intercept a given navigation. The spec says it should be false when the document URL and target URL differ in scheme, username, password, host, or port.
The source fix
Since WebKit is open source, I tracked down the public trail:
PR: WebKit/WebKit#58094 -- “NavigationEvent#canIntercept is true when navigating to a different port”
Bugzilla: Bug 307197 -- reported by Dom Christie on 2026-02-06, fixed by Ahmad Saleem
Commit: 850ce3163e55
Shipped in: Safari Technology Preview 238
Apple’s CVE advisory references a different bug number (Bugzilla #306050, which is private). Bug 307197 is either the public duplicate or the upstream report that the security-track bug was filed against.
The fix is in Source/WebCore/page/Navigation.cpp, function documentCanHaveURLRewritten():
static bool documentCanHaveURLRewritten(const Document& document, const URL& targetURL)
{
// ...existing isSameSite and isSameOrigin checks...
if (!isSameSite && !isSameOrigin)
return false;
+ // https://html.spec.whatwg.org/multipage/nav-history-apis.html#can-have-its-url-rewritten
+ if (documentURL.protocol() != targetURL.protocol()
+ || documentURL.user() != targetURL.user()
+ || documentURL.password() != targetURL.password()
+ || documentURL.host() != targetURL.host()
+ || documentURL.port() != targetURL.port())
+ return false;
+
if (targetURL.protocolIsInHTTPFamily())
return true;You might wonder: doesn’t isSameOriginAs already check the port? It does. Looking at the source, isSameOriginAs() calls isSameSchemeHostPort(), which compares scheme, host, and port.
The problem is the boolean logic upstream of this function. The caller in documentCanHaveURLRewritten() combined both checks with AND: if (!isSameSite && !isSameOrigin) return false. Since localhost:3000 and localhost:3001 share the same registrable domain and scheme, isSameSiteAs returns true. That short-circuits the AND so the isSameOriginAs result never matters. The function falls straight through to return true for any HTTP URL.
Confirming in the binary
I confirmed this by decompiling WebCore::Navigation::innerDispatchNavigateEvent (at 0x1a1307304) from both DSC versions in IDA Pro.
The base version calls two origin checks joined by AND:
// BASE innerDispatchNavigateEvent (23D8133 DSC)
isSameSiteAs = SecurityOrigin::isSameSiteAs(docOrigin, navOrigin);
isSameOriginAs = SecurityOrigin::isSameOriginAs(docOrigin, navOrigin);
if ((isSameSiteAs & 1) == 0 && !isSameOriginAs)
isCrossOrigin = true; // only blocked if BOTH failThe patched version drops isSameSiteAs and adds explicit URL component comparison instead:
// PATCHED innerDispatchNavigateEvent (23D771330a DSC)
if (SecurityOrigin::isSameOriginAs(docOrigin, navOrigin)) {
docHost = URL::host(documentURL);
navHost = URL::host(targetURL);
if (String::equal(docHost, navHost)) {
docPort = URL::port(documentURL);
navPort = URL::port(targetURL);
isCrossOrigin = !String::equal(docPort, navPort);
} else {
isCrossOrigin = true;
}
} else {
isCrossOrigin = true;
}The function grew by 46 ARM64 instructions (1243 -> 1289). The isSameSiteAs call was deleted entirely.
What does this mean in practice? A page on http://localhost:3000 could intercept navigations targeting http://localhost:8080. These are different ports and origins but WebKit lets it through. In a shared-hosting or multi-tenant setup, that’s cross-origin state manipulation.
What Apple didn’t disclose
The CVE covers the Navigation API fix. But this BSI also shipped two other changes that aren’t in the advisory 🙂.
WebGL integer overflow in ANGLE
libANGLE-shared.dylib (Apple’s Metal-backed ANGLE for OpenGL ES) changed the ProvokingVertexHelper::generateIndexBuffer and preconditionIndexBuffer methods. The parameter types narrowed from size_t (64-bit) to int/unsigned int (32-bit), and both functions grew in size generateIndexBuffer went from 680 to 772 bytes per IDA; preconditionIndexBuffer grew similarly per the symbol diff).
I decompiled generateIndexBuffer from both DSC versions in IDA Pro. Here’s the relevant section, side by side.
Base (23D8133, size_t parameters, no overflow check):
LODWORD(v18) = a4 & ~(a4 >> 31);
v36 = v18;
v20 = 2 * v18; // index count — no overflow check
// ... v20 flows directly into buffer allocation sizePatched 23D771330a, int parameters, overflow guard added):
LODWORD(v34) = a4;
v20 = 2LL * a4; // widen to 64-bit before multiply
v35 = v20;
// ... then before using the result:
if (HIDWORD(v20)) // upper 32 bits non-zero → overflow
{
handleError(a2, GL_INVALID_OPERATION,
“Integer overflow.”,
“.../ProvokingVertexHelper.mm”,
“generateIndexBuffer”, 217);
return 1;
}In the base version, 2 * vertexCount uses size_t arithmetic so a large enough input wraps silently and the buffer allocation comes out too small. After the fix, the multiply widens to 64-bit first 2LL * a4), then checks the upper 32 bits. Non-zero means overflow, and the function bails with GL_INVALID_OPERATION instead of allocating a short buffer.
In the Metal rendering path, an undersized index buffer means an out-of-bounds GPU read during WebGL draw calls. The new assertion strings (generateIndexBuffer”, preconditionIndexBuffer”, and the ANGLE source path) confirm this was an intentional hardening pass, not just a type cleanup.
ServiceWorker registration lifetime hardening
WebCore dropped 6 functions and 14 symbols, all in the ServiceWorker server implementation:
HashMap<ProcessQualified<UUID>,WeakRef<SWServerRegistration>>replaced with HashMap<...,Ref<SWServerRegistration>>(weak -> strong references)SWServerRegistrationchanged fromRefCountedAndCanMakeWeakPtrto plainRefCounted(weak pointer support removed)SWServerJobQueue::cancelJobsFromServiceWorkerremoved entirelySeveral hash map lookup/removal helpers for
ProcessQualified<UUID>maps were removed
With the WeakRef-Ref change, the server’s registration map holds a strong reference to each SWServerRegistration, so the registration can’t be deallocated while something still points at it. The cancelJobsFromServiceWorker removal suggests the job cancellation logic moved elsewhere. This is the kind of change you make when weak references can dangle in a concurrent context.
Unlike the Navigation API fix, this change hasn’t landed on public WebKit main; as of this writing, SWServerRegistration still inherits from RefCountedAndCanMakeWeakPtr, m_scopeToRegistrationMap still uses WeakRef, and cancelJobsFromServiceWorker still exists. This is an Apple-internal patch, visible only in the BSI binary. The evidence here comes entirely from symbol-level diffing and decompilation, not source.
Non-security changes
ProductKit and ProductKitCore both went down in version 129.400.11.2.4 -> 129.400.11.2.2), removed device model strings for unannounced hardware (Mac17,6-Mac17,9; iPad16,8-iPad16,11), and got slightly smaller. These were likely pulled into the BSI as dependencies of the WebKit rebuild.
SettingsFoundation removed the _SFDeviceSupportsRFExposure2026OrLater function and associated RF_INTRO_IPHONE_2026” string. RF exposure regulatory check removed or consolidated elsewhere.
WebGPU gained one new symbol Vector<pair<AST::Function*, String>>::expandCapacity). This is a template instantiation pulled in by the WebKit rebuild, not a functional change.
File changes
Only .fseventsd journal entries rotated. No actual filesystem content was added or removed.
Conclusion
Apple’s first BSI shipped one fix for CVE-2026-20643 and two they didn’t mention. The CVE fix was a six-line fix to a URL component comparison that the spec already required. It is the kind of bug where you read the spec, read the code, and wonder how it shipped without the check. The ANGLE integer overflow and ServiceWorker lifetime hardening are arguably more interesting: one is a WebGL-reachable memory safety issue, the other plugs a dangling-reference hole in a concurrent subsystem. Neither made the advisory.
The BSI delivery itself worked as advertised. 26.5 MiB, two cryptex DMGs, no user interaction. If you want to do this kind of teardown yourself: ipsw ota patch rsr gets you mountable DMGs, ipsw diff gives you the symbol-level triage, and IDA on the extracted DSC modules gets you pseudocode to confirm what actually changed. The full diff is on GitHub.
—blacktop
