Search

Patch Diffing CVE-2026-21509: Microsoft Office OLE Security Bypass

태그
RedSpider
Windows
Property
cve_2026_21509_thumbnail.png
작성날짜
2026/04/24

Overview

CVE-2026-21509 is a Security Feature Bypass vulnerability discovered in Microsoft Office.
By default, Office provides a security mechanism that blocks known-vulnerable COM objects. In this case, however, the affected COM object was not included in the block list, which allowed the vulnerability to be exploited in the wild through Office documents. Rather than waiting for its regular update cycle, Microsoft addressed the issue through an Out-of-Band emergency patch in January — and then shipped a second, more substantial fix in the February security update. We analyzed both patches: Part 1 covers the January Out-of-Band patch, and Part 2 covers the follow-up in February.
While analyzing the first patch, we observed unexpected behavior: on the same Office version, the vulnerability was reproducible in some environments but not in others. This write-up documents our investigation into that inconsistency, and summarizes what we learned about Microsoft's response to the vulnerability and Office's CLSID-based COM blocking mechanism.

Part 1: Analyzing CVE-2026-21509 The January Out-of-Band Patch

1. Initial Testing and Validation Results

We tested same PoC on several Office versions. All versions are installed on Windows pro 11 64-bit(26200.7628). At each test we focused on two points to identify the difference.
Shell.explorer.1 is loaded
Check the response about the OLE Object’s block status
These are the versions that we used on the tests:
Version
Distribution
Pre-patch
Post-patch
Office 2016
c2r
Blocked
Blocked
Office 2016
MSI
Vulnerable (KB5002573)
Blocked (KB5002713)
Office 2019
c2r
Blocked
Blocked
Office 2021
c2r
Blocked
Blocked
Office 2024
c2r
Blocked
Blocked
Microsoft 365 Apps
c2r
Blocked
Blocked
Interestingly, only Office 2016 MSI showed a difference between the two states. But every other version blocked the PoC regardless of patch status, which made us wonder where exactly the blocking was coming from. However, We could not find any code level patch in OLE-related dlls.

2. Background: Shell.Explorer.1 and the COM Kill Bit

2.1 Shell.Explorer.1

[Figure 1] Microsoft's Recommendations for Mitigation
According to Microsoft's recommendations regarding this vulnerability, it is recommended to add the EAB22AC3-30C1-11CF-A7EB-0000C05BAE0B clsid to a registry subkey.
Shell.Explorer.1 is a WebBrowser ActiveX control. When embbeded in Office documents, it runs web content through the Internet Explorer engine. There are many variations of this control. Microsoft did not block Shell.Explorer.1 so it could be used as an attack vector.
[Figure 2] WebBrowser ActiveX Controls

2.2 Office's COM Blocking Policy

Microsoft Office implements a COM blocking feature that restricts the activation of potentially dangerous CLSIDs when OLE/COM objects are instantiated inside a document. This mechanism, commonly known as the "COM Kill Bit," operates by filtering specific CLSIDs so that the corresponding COM objects cannot be created.
HandleActivation( this, // ActivationFilterObject instance DWORD dwClassContext, // a2: CLSCTX (e.g., INPROC_SERVER) const GUID *rclsid, // a3: CLSID being requested for activation GUID *pReplacementClsid // a4: output pointer for a replacement CLSID )
C
복사
Return Value
COM Behavior
S_OK (0)
Activation is allowed as-is
REGDB_E_CLASSNOTREG (0x80040155)
Activation denied; reported as "class not registered"
E_ACCESSDENIED (0x80070005)
Activation denied due to access denial
E_POINTER (0x80070057)
pReplacementClsid is NULL
When a COM/OLE object is activated via CoCreateInstance, HandleActivation is called right at the entry point. It decides whether the requested CLSID should be allowed through or blocked.

3. How Blocking Is Applied at Runtime

Most blocking mechanisms make it easy to identify patched parts. However, this CVE cannot be verified via a static analysis. Therefore, we built a PoC document based on the CVE description and used it to trace how the blocking was applied at runtime.
As a result, we confirmed that HandleActivation returns E_ACCESSDENIED (0x80070005) when blocking occurs.
This tells us something important. The COM Kill Bit mechanism was still in use, but the block list was now being extended with entries from an external source.

3.1 Office Config Service

[Figure 3] Automatically protected via Service-side change
Microsoft deployed automatic protection for Microsoft 365 and Office 2021 or later through what they described as a "service-side change." We initially assumed this "service-side change" referred to the c2r auto-update mechanism. Our analysis, however, showed that there is a separate channel — independent of binary updates to Office itself — through which policy data is synchronized via the Office Config Service.
By tracing the filter initialization routine through debugging and reverse engineering, we found that Office issues an outbound network request during startup to officeclient.microsoft.com/config16, which returns XML-formatted configuration data. This config data contained entries that can be interpreted as a CLSID block list, and we confirmed that {EAB22AC3-30C1-11CF-A7EB-0000C05BAE0B} (Shell.Explorer.1) was present in that list.
[Figure 4] ActivationFilter list from Office Config Service
When an Office application is launched, the retrieved policy data is stored in a local cache file and registered into an internal block cache. Afterwards, whenever HandleActivation() is invoked, the requested CLSID is checked against this cache, and E_ACCESSDENIED is returned if there is a match. The cache file path and related metadata can be inspected through the registry.
[Figure 5] Registry metadata for the Config Service cache file
Following the path stored in FilePath, we confirmed that the cache file actually existed.
[Figure 6] Cache file at the FilePath location
This represents that, in addition to the previously documented binary- and registry-based Kill Bit, there is a separate COM block list driven by an external cloud service.
Sources of the Office COM Kill Bit block list:
Hardcoded: embedded in the binary
Registry: added during initialization by reading from the registry
Config Service (external cloud): fetched via a web request during initialization and merged into the block list
To verify that the local config cache was the actual source of the blocking, we designed a simple test. We deleted the cache file and launched Office without network. This cuts off both sources of the block list — the cache file and the office config service. Under these conditions, the vulnerable behavior reproduced even on the latest version, confirming that the block originates entirely from the Config Service.

3.2 Comparison With an Older Config File

We examined a cache file dated December 7, 2025, which was preserved on an previous research VM. When we searched for ActivationFilterGUIDs in that file, the Shell.Explorer.1 CLSID was not present in the entry.
This shows that the data distributed by the Config Service was modified at some point afterwards, and that the CLSID was added to the block list as part of that change. As a consequence, even within the same Office version, the actual blocking behavior could vary depending on when the policy was last retrieved.
[Figure 7] Previous Config File

3.3 Summary of the Runtime Blocking Analysis

To summarize our findings:
Even after the patch, the Shell.Explorer.1 CLSID was not added to the COM Kill Bit entries stored in the existing registry locations or embedded in the binaries.
On the same Office build, whether the vulnerability could be reproduced depended on network conditions.
The blocking is performed by Mso::ActivationFilter::ActivationFilterObject::HandleActivation in Mso30win32client.dll, and the block list is established during the ActivationFilter initialization phase.
The Office Config Service pushed a config file that included Shell.Explorer.1 in its block list.
The config file is cached locally, so blocking is preserved afterwards even in offline environments.
After blocking network access and deleting the cache, the vulnerable behavior was reproducible even on the latest version.
Putting it all together, the overall flow looks like this:
1. Office starts ↓ 2. Attempts to connect to the Config Service (https://officeclient.microsoft.com/config16) ↓ 3. Receives XML response → stored in local cache → registered into Mso::ActivationFilter::activationFilterCache ↓ 4. On HandleActivation() calls, the CLSID is looked up in the cache ↓ 5. On a match, E_ACCESSDENIED is returned (blocked)
Plain Text
복사

Initial Conclusions

As shown above, the emergency patch for CVE-2026-21509 was not delivered as a binary-level fix; instead, it was pushed out by injecting a new entry into the COM Kill Bit block list through the Office Config Service. This approach, however, can leave the fix ineffective depending on the user's environment, which makes it an incomplete patch.
In the following section, we examine the February Patch Tuesday update, which introduced code-level fixes addressing this vulnerability directly.

Part 2: Analysis of the February Code-Level’s Patch

The January emergency update relied solely on a cloud-delivered block list — no code patch. When the February Patch Tuesday update was released, we diffed again and this time found significant changes in both mso30win32client.dll and wwlib.dll. The mso30win32client.dll changes included new COM activation filter categories, expanded CLSID blocklists, and a rewritten HandleActivation function. These were substantial and drew our attention.

1. Patch Analysis

However, these changes were not directly related to blocking the CVE-2026-21509 PoC RTF. Yet the exploit was clearly blocked. — even with network connectivity disabled.
We captured the callstack from the pre-patch version where the PoC successfully triggered:
00 000000fc`593b13c8 00007ffc`156db9ee mso30win32client!MsoHrOleLoad 01 000000fc`593b13d0 00007ffc`149666bd wwlib!HrOleLoadSafe+0x232 02 000000fc`593b1480 00007ffc`156d8e3f wwlib!FLoadFromOleIodt+0x49d 03 000000fc`593b1760 00007ffc`156dc670 wwlib!FCreateIodtFromOlestreamXsz+0x20f <- blocked here in new version 04 000000fc`593b1a70 00007ffc`1588a678 wwlib!IodtLoadObjectFromRtf+0xa0 05 000000fc`593b1af0 00007ffc`146609b6 wwlib!PopRtfStateRdsOle+0x228 06 000000fc`593b1c60 00007ffc`1465e3b4 wwlib!PopRtfState+0x1586 07 000000fc`593bc7b0 00007ffc`146586bd wwlib!RtfInRare+0x2c08
Plain Text
복사
In the new version, execution stopped at FCreateIodtFromOlestreamXsz in wwlib.dll — it returned E_FAIL before ever reaching FLoadFromOleIodt. The fix was here.
In the old version of FCreateIodtFromOlestreamXsz:
1.
Read the CLSID from IStorage via ReadClassStg
2.
Compare it against GUID_00030003-0000-0000-C000-000000000046
3.
If they match, or (*(v15+156) & 0x1000) != 0, return success without OLE activation
4.
Otherwise, call FLoadFromOleIodt — which proceeds to OleLoadCoCreateInstance
pre-patch:
char __fastcall FCreateIodtFromOlestreamXsz( struct DOD *a1, struct _OLESTREAM *a2, __int16 a3, unsigned int *a4, unsigned int a5, struct IStorage *a6, unsigned __int16 a7, int a8, const wchar_t *lpsz) { ReadClassStg(*(LPSTORAGE *)(v15 + 88), &pclsid); v16 = *(_QWORD *)&pclsid.Data1 - *(_QWORD *)&GUID_00030003_0000_0000_c000_000000000046.Data1; if ( *(_QWORD *)&pclsid.Data1 == *(_QWORD *)&GUID_00030003_0000_0000_c000_000000000046.Data1 ) v16 = *(_QWORD *)pclsid.Data4 - *(_QWORD *)GUID_00030003_0000_0000_c000_000000000046.Data4; if ( !v16 || (*(_WORD *)(v15 + 156) & 0x1000) != 0 ) { _hrLastContainer = 0; if ( !v32 ) return 1; v30 = *(void (**)(void))(*(_QWORD *)v32 + 16LL); v30(); return 1; } if ( (unsigned int)FLoadFromOleIodt(v33, v14, a8, 0x22021098u) ) { //... } }
C
복사
In the new version, an a10(bool type) parameter is added (10th parameter), and a blocking gate is inserted before the existing logic:
1.
If a10 == true and (*(v23+156) & 0x1000) == 0, return E_FAIL — OLE loading is aborted
2.
Read the CLSID from IStorage via ReadClassStg
3.
If the CLSID matches GUID_00030003... or (*(v23+156) & 0x1000) != 0, break out without calling FLoadFromOleIodt
4.
Only if all checks pass, call FLoadFromOleIodt
We confirmed through dynamic debugging that *(v23+156) is 0x0000 in both old and new versions. This value is read from the \x03ObjInfo sub-stream inside the OLE compound document. The attack RTF does not include this stream — when FLoadObjInfoFromStorage tries to open it via PistmOpenXszCore, the call fails with STG_E_FILENOTFOUND (0x80030002), leaving the field at its initialized value of zero. The a10 value was 0x1 (true).
Therefore, the only behavioral difference is the newly added parameter a10.
post-patch:
char __fastcall FCreateIodtFromOlestreamXsz( struct DOD *a1, unsigned __int64 a2, __int16 a3, unsigned int *a4, signed int a5, struct IStorage *a6, unsigned __int16 a7, int a8, const wchar_t *lpsz, bool a10) // new version added { if ( a10 && (*(_WORD *)(v23 + 156) & 0x1000) == 0 ) // Patch Point { LABEL_67: LODWORD(v13) = 0x80004005; LABEL_68: ReleaseIodt(v22); DeleteObjectIodt(v22); *v38 = 0; _hrLastContainer = (int)v13; LABEL_69: if ( v39 ) (*(void (__fastcall **)(__int64))(*(_QWORD *)v39 + 16LL))(v39); return 0; } ReadClassStg(*(LPSTORAGE *)(v23 + 88), &pclsid); if ( !memcmp_0(&pclsid, &GUID_00030003_0000_0000_c000_000000000046, 0x10u) || (*(_WORD *)(v23 + 156) & 0x1000) != 0 ) break; if ( !(unsigned int)FLoadFromOleIodt(v40, v22, a8, 0x22021098u) ) }
C
복사

1.1 The RTF Keyword Dispatch Chain

To understand what this ‘a10’ represents, we had to reverse-engineer how Word’s RTF parser processes OLE-related keywords.
When RtfInRare processes an OLE-related keyword like objhtml, it calls FSearchRgrsym to resolve the keyword string to an integer ID (rsym).
_OWORD *__fastcall RtfInRare(struct RIBL **a1, _QWORD *a2) { //... v248 = FSearchRgrsym(v301, v310); v249 = *a1; v250 = 0; if ( !v248 ) { if ( !*((_BYTE *)v249 + 15) ) goto LABEL_682; goto LABEL_643; } *((_BYTE *)v249 + 15) = 0; v251 = 5; if ( SLODWORD(v310[0]) < 1779 ) { v253 = *(__int64 *)((char *)&rgrsymWord + 12 * SLODWORD(v310[0])); v254 = dword_18275C0F8[3 * SLODWORD(v310[0])]; goto LABEL_561; } //... LABEL_562: v312[0] = (struct VECSDR *)v253; //... LABEL_572: v244 = (int)v312[0]; //... LABEL_639: ApplyPropChange(v254, v299, v244, a1); }
C
복사
FSearchRgrsym resolves the keyword through three stages:
1.
Bucket Index: A precomputed table maps the first character to a (lo, hi) range within a sorted keyword array. For 'o', this narrows the search to positions (994, 1034).
2.
Binary Search: Within that range, _rgstRtfSorted (an alphabetically sorted array of Pascal-string pointers) is binary-searched to find the matching entry.
3.
Permutation: The sorted position is converted to the canonical rsym via dword_18276F1E0. Sorted order and rsym order differ — rsym numbers were assigned historically, not alphabetically. For objhtml, this yields sorted position 1006 → dword_18276F1E0[1006] → rsym 0x3DA(986).
[Figure 8] Calling FSearchRgrsym with parameter .objhtml
This rsym is used to index into rgrsymWord (a metadata table with 12-byte stride). Each field used as different purposes.
[Figure 9] rgrsymWord value at index 986
We named the field as the purpose.
00 00 00 06 | 00 00 01 01 | 00 00 02 5F val=0x6 |BYTE4=0x1 |prop=0x25F |BYTE5=0x1
Plain Text
복사
Offset
Size
field
Purpose
+0
4
val
raw value to pass to the handler
+4
4
flags (BYTE4=dispatch, BYTE5=case)
Dispatch control
+8
4
prop
Property number for ApplyPropChange
BYTE4 and BYTE5 control how RtfInRare dispatches the keyword.
switch ( BYTE5(v312[0]) ) { case 1u: goto LABEL_639; case 2u: //... case 3u: //... case 4u: //... case 6u: //... } //... LABEL_639: ApplyPropChange(v254, v299, v244, a1);
C
복사
The ApplyPropChange function is called due to the value of BYTE5, regardless of the value of BYTE4. The prop and val becomes the 1st and 3rd parameters of ApplyPropChange.
In ApplyPropChange, the switch is on a1 (the property number). Under case 607, the raw value a3 (0x6) is written into bits 4-6 of the OLE substate buffer: 16 * (0x6 & 7) = 0x60.
void __fastcall ApplyPropChange(int a1, unsigned int a2, int a3, struct RIBL **a4) { //... v6 = a1; v525 = a3; //... switch ( (int)v6 ) { case 597: case 605: case 606: case 607: case 610: v387 = *((_QWORD *)*v4 + 33); v33 = v523; if ( v387 ) *(_WORD *)(*(_QWORD *)v387 + 32LL) = *(_WORD *)(*(_QWORD *)v387 + 32LL) & 0xFF8F | (16 * (v525 & 7)); goto LABEL_1627; }
C
복사
When the RTF \object group closes, PopRtfStateRdsOle reads back the subtype bits from the buffer. The value 0x60 is extracted, and the expression ((0x60 - 0x50) & 0xFFFFFFEF) == 0 evaluates to TRUE — this expression is only true for 0x50 (\objocx) and 0x60 (\objhtml). The resulting boolean is passed as the 5th argument to IodtLoadObjectFromRtf, which forwards it directly to FCreateIodtFromOlestreamXsz as a10.
void __fastcall PopRtfStateRdsOle(struct RIBL **a1) { //... ObjectFromRtf = IodtLoadObjectFromRtf( (struct DOD *)v4, **((const struct RHPCCHW ***)*a1 + 33), (unsigned int *)&v39, (const wchar_t *)*a1 + 20562, ((v9 - 0x50) & 0xFFFFFFEF) == 0); //a5 //... } //... }
C
복사
__int64 __fastcall IodtLoadObjectFromRtf( struct DOD *a1, const struct RHPCCHW *a2, unsigned int *a3, const wchar_t *a4, bool a5) { //... if ( FCreateIodtFromOlestreamXsz(a1, (struct WWSTREAM *)v9, 1, &v21, v12, v17, 0, 1, a4, a5) ) //a5 -> a10 { //... } }
C
복사
char __fastcall FCreateIodtFromOlestreamXsz( struct DOD *a1, unsigned __int64 a2, __int16 a3, unsigned int *a4, signed int a5, struct IStorage *a6, unsigned __int16 a7, int a8, const wchar_t *lpsz, bool a10) // new version added { if ( a10 && (*(_WORD *)(v23 + 156) & 0x1000) == 0 ) // Patch Point { LABEL_67: LODWORD(v13) = 0x80004005; //error code LABEL_68: ReleaseIodt(v22); DeleteObjectIodt(v22); *v38 = 0; _hrLastContainer = (int)v13; LABEL_69: if ( v39 ) (*(void (__fastcall **)(__int64))(*(_QWORD *)v39 + 16LL))(v39); return 0; //return before reaching FLoadFromOleIodt } ReadClassStg(*(LPSTORAGE *)(v23 + 88), &pclsid); if ( !memcmp_0(&pclsid, &GUID_00030003_0000_0000_c000_000000000046, 0x10u) || (*(_WORD *)(v23 + 156) & 0x1000) != 0 ) break; if ( !(unsigned int)FLoadFromOleIodt(v40, v22, a8, 0x22021098u) ) }
C
복사
With a10 = true and *(_WORD *)(v23 + 156) = 0x0000, the newly added gate in FCreateIodtFromOlestreamXsz evaluates to true - causing the function to set error(0x80004005) and return 0 before ever reaching FLoadFromOleIodt. As a result, the entire OLE activation chain(FLoadFromOleIodtHrOleLoadSafeOleLoadCoCreateInstance) is never executed, and Shell.Explorer.12 is never instantiated.

1.2 mso30win32client.dll Patch Analysis

Another patches located in mso30win32client.dll, covering scenarios where CLSID_WebBrowser_V1 (Shell.Explorer.1) is activated from paths other than RTF.
The following functions are relevant to CVE-2026-21509:
Mso::ActivationFilter::ActivationFilterObject::Init
Mso::ActivationFilter::ActivationFilterObject::PopulateScopedActivationFilterCache
Mso::ActivationFilter::ActivationFilterObject::HandleActivation
Mso::Trident::TryClassifyTridentCLSID
For non-RTF formats (e.g., .doc), the a10 parameter described above is not set to true — the non-RTF callers of FCreateIodtFromOlestreamXsz hardcode a10 = false. In this case, the wwlib gate does not fire, and OLE loading proceeds to CoCreateInstance.
To address this vulnerability, the February patch introduces a new blocklist category, Type 6, into the COM activation filter of mso30win32client.dll.
ActivationFilterObject::Init runs at Office startup and populates the COM activation filter caches. The February patch adds a new loop that registers CLSID_WebBrowser_V1(Shell.Explorer.1) and CLSID_WebBrowser into a Type 6 cache with ActivationAction = Block:
v28 = &unk_180AD19A8; //CLSID_WebBrowser_V1(Shell.Explorer.1) v48.Data1 = 6; do { v52 = *v28; v53 = 1; std::_Tree<std::_Tmap_traits<_GUID,bool,Mso::Memory::LessFunctor<_GUID>,std::allocator<std::pair<_GUID const,bool>>,0>>::_Emplace<std::pair<_GUID,bool>>( (char *)this + 312, &v50, &v52); LODWORD(v49) = 0; v52 = 0; Mso::ActivationFilter::ActivationFilterObject::PopulateScopedActivationFilterCache( v28++, (const enum Mso::ActivationFilter::ActivationAction *)&v49, &v52, (const enum Mso::ComSecurity::ScopedActivationHandlerType *)&v48); //Type(6) } while ( v28 != (const struct _GUID *)&Mso::Authentication::FederationAADUserObjectIdHandler::`vftable' );
C
복사
[Figure 10] unk_180AD19A8 → Shell.Explorer.1({EAB22AC3-30C1-11CF-A7EB-0000C05BAE0B})
And at Mso::ActivationFilter::ActivationFilterObject::PopulateScopedActivationFilterCache, we can see the patch adds case 6 to switch statement.
pre-patch:
unsigned __int8 __fastcall Mso::ActivationFilter::ActivationFilterObject::PopulateScopedActivationFilterCache( const struct _GUID *a1, const enum Mso::ActivationFilter::ActivationAction *a2, struct _GUID *a3, const enum Mso::ComSecurity::ScopedActivationHandlerType *a4) //Type 6 { //... if ( *(_DWORD *)a4 == 1 ) { //... } else { switch ( *(_DWORD *)a4 ) { case 2: v8 = (Mso::ActivationFilter::ActivationFilterCache *)&unk_180F0CD38; break; case 3: v8 = (Mso::ActivationFilter::ActivationFilterCache *)&unk_180F0CD80; break; case 4: v8 = (Mso::ActivationFilter::ActivationFilterCache *)&unk_180F0CD50; break; case 5: v8 = (Mso::ActivationFilter::ActivationFilterCache *)&unk_180F0CD68; break; default: MsoShipAssertTagProc(0x2049885B, a1, a2); return 0; } v10 = *a3; return Mso::ActivationFilter::ActivationFilterCache::AddCLSIDActivation(v8, a1, a2, &v10); } }
C
복사
post-patch:
// Hidden C++ exception states: #wind=1 unsigned __int8 __fastcall Mso::ActivationFilter::ActivationFilterObject::PopulateScopedActivationFilterCache( const struct _GUID *a1, const enum Mso::ActivationFilter::ActivationAction *a2, struct _GUID *a3, const enum Mso::ComSecurity::ScopedActivationHandlerType *a4) //Type 6 { //... if ( *(_DWORD *)a4 == 1 ) { //... } else { switch ( *(_DWORD *)a4 ) { case 2: v8 = (#693 *)&unk_180F31410; break; case 3: v8 = (#693 *)&unk_180F31470; break; case 4: v8 = (#693 *)&unk_180F31428; break; case 5: v8 = (#693 *)&unk_180F31440; break; case 6: //Type 6 added v8 = (#693 *)&unk_180F31458; break; default: MsoShipAssertTagProc(541689947); return 0; } v10 = *a3; return Mso::ActivationFilter::ActivationFilterCache::AddCLSIDActivation(v8, a1, a2, &v10); } }
C
복사
When CoCreateInstance is called with CLSID_WebBrowser_V1, the Type 6 cache lookup hits, HandleActivation blocks the CLSID if found.
TryClassifyTridentCLSID classifies CLSID_WebBrowser_V1 as a Trident (IE) component (return 6) and passes the result to a telemetry function.
There is only 5 CLSIDs recognized in pre-patch.
CLSID_HTMLDocument: HTML Document(25336920-03F9-11CF-8FD0-00AA00686F13)
CLSID_MHTMLDocument: MHTML Document(3050F3D9-98B5-11CF-BB82-00AA00BDCE0B)
CLSID_XHTMLDocument: XHTML Document(30590067-98B5-11CF-BB82-00AA00BDCE0B)
CLSID_WebBrowser: Microsoft Web Browser(8856F961-340A-11D0-A96B-00C04FD705A2)
CLSID_InternetExplorer: Internet Explorer(Ver 1.0)(0002DF01-0000-0000-C000-000000000046)
pre-patch:
// Hidden C++ exception states: #wind=1 char __fastcall Mso::Trident::TryClassifyTridentCLSID(_QWORD *a1) { __int64 v1; // rax __int64 v2; // rax __int64 v3; // rax __int64 v4; // rax v1 = *a1 - *(_QWORD *)&CLSID_HTMLDocument.Data1; if ( *a1 == *(_QWORD *)&CLSID_HTMLDocument.Data1 ) v1 = a1[1] - *(_QWORD *)CLSID_HTMLDocument.Data4; if ( !v1 ) return 1; v2 = *a1 - *(_QWORD *)&CLSID_MHTMLDocument.Data1; if ( *a1 == *(_QWORD *)&CLSID_MHTMLDocument.Data1 ) v2 = a1[1] - *(_QWORD *)CLSID_MHTMLDocument.Data4; if ( !v2 ) return 2; v3 = *a1 - *(_QWORD *)&CLSID_XHTMLDocument.Data1; if ( *a1 == *(_QWORD *)&CLSID_XHTMLDocument.Data1 ) v3 = a1[1] - *(_QWORD *)CLSID_XHTMLDocument.Data4; if ( !v3 ) return 3; v4 = *a1 - *(_QWORD *)&CLSID_WebBrowser.Data1; if ( *a1 == *(_QWORD *)&CLSID_WebBrowser.Data1 ) v4 = a1[1] - *(_QWORD *)CLSID_WebBrowser.Data4; if ( v4 ) return memcmp_0(a1, &CLSID_InternetExplorer, 0x10u) == 0 ? 5 : 0; else return 4; }
C
복사
But as you can see, CLSID_WebBrowser_V1(EAB22AC3-30C1-11CF-A7EB-0000C05BAE0B) is added in post-patch.
post-patch:
char __fastcall Mso::Trident::TryClassifyTridentCLSID(_QWORD *a1) { __int64 v1; // rax __int64 v3; // rax __int64 v4; // rax __int64 v5; // rax __int64 v6; // rax v1 = *a1 - *(_QWORD *)&CLSID_HTMLDocument.Data1; if ( *a1 == *(_QWORD *)&CLSID_HTMLDocument.Data1 ) v1 = a1[1] - *(_QWORD *)CLSID_HTMLDocument.Data4; if ( !v1 ) return 1; v3 = *a1 - *(_QWORD *)&CLSID_MHTMLDocument.Data1; if ( *a1 == *(_QWORD *)&CLSID_MHTMLDocument.Data1 ) v3 = a1[1] - *(_QWORD *)CLSID_MHTMLDocument.Data4; if ( !v3 ) return 2; v4 = *a1 - *(_QWORD *)&CLSID_XHTMLDocument.Data1; if ( *a1 == *(_QWORD *)&CLSID_XHTMLDocument.Data1 ) v4 = a1[1] - *(_QWORD *)CLSID_XHTMLDocument.Data4; if ( !v4 ) return 3; v5 = *a1 - *(_QWORD *)&CLSID_WebBrowser.Data1; if ( *a1 == *(_QWORD *)&CLSID_WebBrowser.Data1 ) v5 = a1[1] - *(_QWORD *)CLSID_WebBrowser.Data4; if ( !v5 ) return 4; v6 = *a1 - *(_QWORD *)&CLSID_WebBrowser_V1.Data1; if ( *a1 == *(_QWORD *)&CLSID_WebBrowser_V1.Data1 ) v6 = a1[1] - *(_QWORD *)CLSID_WebBrowser_V1.Data4; if ( v6 ) return memcmp_0(a1, &CLSID_InternetExplorer, 0x10u) == 0 ? 5 : 0; else return 6; }
C
복사
It does not perform direct blocking and serves monitoring/logging purposes only.

2. Conclusion

The patch for this CVE was not a simple conditional addition to a single function — it was the establishment of a new information flow that propagates the RTF parser's keyword dispatch result all the way to the OLE loader.
The changes were distributed across several functions (PopRtfStateRdsOle → IodtLoadObjectFromRtf → FCreateIodtFromOlestreamXsz), and the core blocking logic amounted to adding a single a10 AND condition to an existing 0x1000 bit check in FCreateIodtFromOlestreamXsz.
The RTF path fix operates unconditionally — no ChangeGate, no runtime flag. This makes sense: CVE-2026-21509 was actively exploited through RTF documents, so the primary attack vector had to be shut down immediately.
The non-RTF defenses (mso30's Type 6 COM filter) are gated behind runtime feature flags. These protect against vectors not observed in the wild (.doc embeds, VBA, etc.), where the risk of breaking legitimate functionality is harder to assess. ChangeGates allow Microsoft to enable each layer selectively while monitoring for regressions.

Reference